Refactor modal and badge components for consistency

Standardizes import statements, string quoting, and className usage across modal and badge components. Improves code readability and consistency, updates formatting, and enhances maintainability without changing component logic.
This commit is contained in:
CanbiZ
2025-10-20 20:03:38 +02:00
parent fc6e13946d
commit 9e1975dd1d
15 changed files with 887 additions and 647 deletions

View File

@@ -1,23 +1,27 @@
'use client';
"use client";
import { useState } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { useAuth } from './AuthProvider';
import { Lock, User, AlertCircle } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import { useTranslation } from '@/lib/i18n/useTranslation';
import { useState } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { useAuth } from "./AuthProvider";
import { Lock, User, AlertCircle } from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useTranslation } from "@/lib/i18n/useTranslation";
interface AuthModalProps {
isOpen: boolean;
}
export function AuthModal({ isOpen }: AuthModalProps) {
const { t } = useTranslation('authModal');
useRegisterModal(isOpen, { id: 'auth-modal', allowEscape: false, onClose: () => null });
const { t } = useTranslation("authModal");
useRegisterModal(isOpen, {
id: "auth-modal",
allowEscape: false,
onClose: () => null,
});
const { login } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -29,7 +33,7 @@ export function AuthModal({ isOpen }: AuthModalProps) {
const success = await login(username, password);
if (!success) {
setError(t('error'));
setError(t("error"));
}
setIsLoading(false);
@@ -38,33 +42,38 @@ export function AuthModal({ isOpen }: AuthModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
{/* Header */}
<div className="flex items-center justify-center p-6 border-b border-border">
<div className="border-border flex items-center justify-center border-b p-6">
<div className="flex items-center gap-3">
<Lock className="h-8 w-8 text-primary" />
<h2 className="text-2xl font-bold text-card-foreground">{t('title')}</h2>
<Lock className="text-primary h-8 w-8" />
<h2 className="text-card-foreground text-2xl font-bold">
{t("title")}
</h2>
</div>
</div>
{/* Content */}
<div className="p-6">
<p className="text-muted-foreground text-center mb-6">
{t('description')}
<p className="text-muted-foreground mb-6 text-center">
{t("description")}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-2">
{t('username.label')}
<label
htmlFor="username"
className="text-foreground mb-2 block text-sm font-medium"
>
{t("username.label")}
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<User className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
id="username"
type="text"
placeholder={t('username.placeholder')}
placeholder={t("username.placeholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoading}
@@ -75,15 +84,18 @@ export function AuthModal({ isOpen }: AuthModalProps) {
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-2">
{t('password.label')}
<label
htmlFor="password"
className="text-foreground mb-2 block text-sm font-medium"
>
{t("password.label")}
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Lock className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
id="password"
type="password"
placeholder={t('password.placeholder')}
placeholder={t("password.placeholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
@@ -94,7 +106,7 @@ export function AuthModal({ isOpen }: AuthModalProps) {
</div>
{error && (
<div className="flex items-center gap-2 p-3 bg-error/10 text-error-foreground border border-error/20 rounded-md">
<div className="bg-error/10 text-error-foreground border-error/20 flex items-center gap-2 rounded-md border p-3">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">{error}</span>
</div>
@@ -105,7 +117,7 @@ export function AuthModal({ isOpen }: AuthModalProps) {
disabled={isLoading || !username.trim() || !password.trim()}
className="w-full"
>
{isLoading ? t('actions.signingIn') : t('actions.signIn')}
{isLoading ? t("actions.signingIn") : t("actions.signIn")}
</Button>
</form>
</div>

View File

@@ -1,94 +1,108 @@
'use client';
"use client";
import React from 'react';
import { useTranslation } from '@/lib/i18n/useTranslation';
import React from "react";
import { useTranslation } from "@/lib/i18n/useTranslation";
interface BadgeProps {
variant: 'type' | 'updateable' | 'privileged' | 'status' | 'note' | 'execution-mode';
variant:
| "type"
| "updateable"
| "privileged"
| "status"
| "note"
| "execution-mode";
type?: string;
noteType?: 'info' | 'warning' | 'error';
status?: 'success' | 'failed' | 'in_progress';
executionMode?: 'local' | 'ssh';
noteType?: "info" | "warning" | "error";
status?: "success" | "failed" | "in_progress";
executionMode?: "local" | "ssh";
children: React.ReactNode;
className?: string;
}
export function Badge({ variant, type, noteType, status, executionMode, children, className = '' }: BadgeProps) {
export function Badge({
variant,
type,
noteType,
status,
executionMode,
children,
className = "",
}: BadgeProps) {
const getTypeStyles = (scriptType: string) => {
switch (scriptType.toLowerCase()) {
case 'ct':
return 'bg-primary/10 text-primary border-primary/20';
case 'addon':
return 'bg-primary/10 text-primary border-primary/20';
case 'vm':
return 'bg-success/10 text-success border-success/20';
case 'pve':
return 'bg-warning/10 text-warning border-warning/20';
case "ct":
return "bg-primary/10 text-primary border-primary/20";
case "addon":
return "bg-primary/10 text-primary border-primary/20";
case "vm":
return "bg-success/10 text-success border-success/20";
case "pve":
return "bg-warning/10 text-warning border-warning/20";
default:
return 'bg-muted text-muted-foreground border-border';
return "bg-muted text-muted-foreground border-border";
}
};
const getVariantStyles = () => {
switch (variant) {
case 'type':
return `inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${type ? getTypeStyles(type) : getTypeStyles('unknown')}`;
case "type":
return `inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${type ? getTypeStyles(type) : getTypeStyles("unknown")}`;
case 'updateable':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20';
case "updateable":
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20";
case 'privileged':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20';
case "privileged":
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20";
case 'status':
case "status":
switch (status) {
case 'success':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20';
case 'failed':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-error/10 text-error border border-error/20';
case 'in_progress':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning border border-warning/20';
case "success":
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20";
case "failed":
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-error/10 text-error border border-error/20";
case "in_progress":
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning border border-warning/20";
default:
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border';
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border";
}
case 'execution-mode':
case "execution-mode":
switch (executionMode) {
case 'local':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20';
case 'ssh':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20';
case "local":
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20";
case "ssh":
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20";
default:
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border';
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border";
}
case 'note':
case "note":
switch (noteType) {
case 'warning':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning border border-warning/20';
case 'error':
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20';
case "warning":
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning border border-warning/20";
case "error":
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20";
default:
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20';
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20";
}
default:
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border';
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border";
}
};
// Format the text for type badges
const formatText = () => {
if (variant === 'type' && type) {
if (variant === "type" && type) {
switch (type.toLowerCase()) {
case 'ct':
return 'LXC';
case 'addon':
return 'ADDON';
case 'vm':
return 'VM';
case 'pve':
return 'PVE';
case "ct":
return "LXC";
case "addon":
return "ADDON";
case "vm":
return "VM";
case "pve":
return "PVE";
default:
return type.toUpperCase();
}
@@ -97,50 +111,78 @@ export function Badge({ variant, type, noteType, status, executionMode, children
};
return (
<span className={`${getVariantStyles()} ${className}`}>
{formatText()}
</span>
<span className={`${getVariantStyles()} ${className}`}>{formatText()}</span>
);
}
// Convenience components for common use cases
export const TypeBadge = ({ type, className }: { type: string; className?: string }) => (
export const TypeBadge = ({
type,
className,
}: {
type: string;
className?: string;
}) => (
<Badge variant="type" type={type} className={className}>
{type}
</Badge>
);
export const UpdateableBadge = ({ className }: { className?: string }) => {
const { t } = useTranslation('badge');
const { t } = useTranslation("badge");
return (
<Badge variant="updateable" className={className}>
{t('updateable')}
{t("updateable")}
</Badge>
);
};
export const PrivilegedBadge = ({ className }: { className?: string }) => {
const { t } = useTranslation('badge');
const { t } = useTranslation("badge");
return (
<Badge variant="privileged" className={className}>
{t('privileged')}
{t("privileged")}
</Badge>
);
};
export const StatusBadge = ({ status, children, className }: { status: 'success' | 'failed' | 'in_progress'; children: React.ReactNode; className?: string }) => (
export const StatusBadge = ({
status,
children,
className,
}: {
status: "success" | "failed" | "in_progress";
children: React.ReactNode;
className?: string;
}) => (
<Badge variant="status" status={status} className={className}>
{children}
</Badge>
);
export const ExecutionModeBadge = ({ mode, children, className }: { mode: 'local' | 'ssh'; children: React.ReactNode; className?: string }) => (
export const ExecutionModeBadge = ({
mode,
children,
className,
}: {
mode: "local" | "ssh";
children: React.ReactNode;
className?: string;
}) => (
<Badge variant="execution-mode" executionMode={mode} className={className}>
{children}
</Badge>
);
export const NoteBadge = ({ noteType, children, className }: { noteType: 'info' | 'warning' | 'error'; children: React.ReactNode; className?: string }) => (
export const NoteBadge = ({
noteType,
children,
className,
}: {
noteType: "info" | "warning" | "error";
children: React.ReactNode;
className?: string;
}) => (
<Badge variant="note" noteType={noteType} className={className}>
{children}
</Badge>

View File

@@ -1,10 +1,10 @@
'use client';
"use client";
import { useMemo, useState } from 'react';
import { Button } from './ui/button';
import { AlertTriangle, Info } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import { useTranslation } from '~/lib/i18n/useTranslation';
import { useMemo, useState } from "react";
import { Button } from "./ui/button";
import { AlertTriangle, Info } from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useTranslation } from "~/lib/i18n/useTranslation";
interface ConfirmationModalProps {
isOpen: boolean;
@@ -12,7 +12,7 @@ interface ConfirmationModalProps {
onConfirm: () => void;
title: string;
message: string;
variant: 'simple' | 'danger';
variant: "simple" | "danger";
confirmText?: string; // What the user must type for danger variant
confirmButtonText?: string;
cancelButtonText?: string;
@@ -27,19 +27,19 @@ export function ConfirmationModal({
variant,
confirmText,
confirmButtonText,
cancelButtonText
cancelButtonText,
}: ConfirmationModalProps) {
const { t } = useTranslation('confirmationModal');
const { t: tc } = useTranslation('common.actions');
const [typedText, setTypedText] = useState('');
const isDanger = variant === 'danger';
const { t } = useTranslation("confirmationModal");
const { t: tc } = useTranslation("common.actions");
const [typedText, setTypedText] = useState("");
const isDanger = variant === "danger";
const allowEscape = useMemo(() => !isDanger, [isDanger]);
// Use provided button texts or fallback to translations
const finalConfirmText = confirmButtonText ?? tc('confirm');
const finalCancelText = cancelButtonText ?? tc('cancel');
const finalConfirmText = confirmButtonText ?? tc("confirm");
const finalCancelText = cancelButtonText ?? tc("cancel");
useRegisterModal(isOpen, { id: 'confirmation-modal', allowEscape, onClose });
useRegisterModal(isOpen, { id: "confirmation-modal", allowEscape, onClose });
if (!isOpen) return null;
const isConfirmEnabled = isDanger ? typedText === confirmText : true;
@@ -47,57 +47,67 @@ export function ConfirmationModal({
const handleConfirm = () => {
if (isConfirmEnabled) {
onConfirm();
setTypedText(''); // Reset for next time
setTypedText(""); // Reset for next time
}
};
const handleClose = () => {
onClose();
setTypedText(''); // Reset when closing
setTypedText(""); // Reset when closing
};
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
{/* Header */}
<div className="flex items-center justify-center p-6 border-b border-border">
<div className="border-border flex items-center justify-center border-b p-6">
<div className="flex items-center gap-3">
{isDanger ? (
<AlertTriangle className="h-8 w-8 text-error" />
<AlertTriangle className="text-error h-8 w-8" />
) : (
<Info className="h-8 w-8 text-info" />
<Info className="text-info h-8 w-8" />
)}
<h2 className="text-2xl font-bold text-card-foreground">{title}</h2>
<h2 className="text-card-foreground text-2xl font-bold">{title}</h2>
</div>
</div>
{/* Content */}
<div className="p-6">
<p className="text-sm text-muted-foreground mb-6">
{message}
</p>
<p className="text-muted-foreground mb-6 text-sm">{message}</p>
{/* Type-to-confirm input for danger variant */}
{isDanger && confirmText && (
<div className="mb-6">
<label className="block text-sm font-medium text-foreground mb-2">
{t('typeToConfirm', { values: { text: confirmText } }).split(confirmText)[0]}
<code className="bg-muted px-2 py-1 rounded text-sm">{confirmText}</code>
{t('typeToConfirm', { values: { text: confirmText } }).split(confirmText)[1]}
<label className="text-foreground mb-2 block text-sm font-medium">
{
t("typeToConfirm", { values: { text: confirmText } }).split(
confirmText,
)[0]
}
<code className="bg-muted rounded px-2 py-1 text-sm">
{confirmText}
</code>
{
t("typeToConfirm", { values: { text: confirmText } }).split(
confirmText,
)[1]
}
</label>
<input
type="text"
value={typedText}
onChange={(e) => setTypedText(e.target.value)}
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder={t('placeholder', { values: { text: confirmText } })}
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 focus:ring-2 focus:outline-none"
placeholder={t("placeholder", {
values: { text: confirmText },
})}
autoComplete="off"
/>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row justify-end gap-3">
<div className="flex flex-col justify-end gap-3 sm:flex-row">
<Button
onClick={handleClose}
variant="outline"

View File

@@ -1,10 +1,10 @@
'use client';
"use client";
import { useEffect } from 'react';
import { Button } from './ui/button';
import { AlertCircle, CheckCircle } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import { useTranslation } from '~/lib/i18n/useTranslation';
import { useEffect } from "react";
import { Button } from "./ui/button";
import { AlertCircle, CheckCircle } from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useTranslation } from "~/lib/i18n/useTranslation";
interface ErrorModalProps {
isOpen: boolean;
@@ -12,7 +12,7 @@ interface ErrorModalProps {
title: string;
message: string;
details?: string;
type?: 'error' | 'success';
type?: "error" | "success";
}
export function ErrorModal({
@@ -21,11 +21,11 @@ export function ErrorModal({
title,
message,
details,
type = 'error'
type = "error",
}: ErrorModalProps) {
const { t } = useTranslation('errorModal');
const { t: tc } = useTranslation('common.actions');
useRegisterModal(isOpen, { id: 'error-modal', allowEscape: true, onClose });
const { t } = useTranslation("errorModal");
const { t: tc } = useTranslation("common.actions");
useRegisterModal(isOpen, { id: "error-modal", allowEscape: true, onClose });
// Auto-close after 10 seconds
useEffect(() => {
if (isOpen) {
@@ -39,41 +39,47 @@ export function ErrorModal({
if (!isOpen) return null;
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-lg w-full border border-border">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border w-full max-w-lg rounded-lg border shadow-xl">
{/* Header */}
<div className="flex items-center justify-center p-6 border-b border-border">
<div className="border-border flex items-center justify-center border-b p-6">
<div className="flex items-center gap-3">
{type === 'success' ? (
<CheckCircle className="h-8 w-8 text-success" />
{type === "success" ? (
<CheckCircle className="text-success h-8 w-8" />
) : (
<AlertCircle className="h-8 w-8 text-error" />
<AlertCircle className="text-error h-8 w-8" />
)}
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
<h2 className="text-foreground text-xl font-semibold">{title}</h2>
</div>
</div>
{/* Content */}
<div className="p-6">
<p className="text-sm text-foreground mb-4">{message}</p>
<p className="text-foreground mb-4 text-sm">{message}</p>
{details && (
<div className={`rounded-lg p-3 ${
type === 'success'
? 'bg-success/10 border border-success/20'
: 'bg-error/10 border border-error/20'
}`}>
<p className={`text-xs font-medium mb-1 ${
type === 'success'
? 'text-success-foreground'
: 'text-error-foreground'
}`}>
{type === 'success' ? t('detailsLabel') : t('errorDetailsLabel')}
<div
className={`rounded-lg p-3 ${
type === "success"
? "bg-success/10 border-success/20 border"
: "bg-error/10 border-error/20 border"
}`}
>
<p
className={`mb-1 text-xs font-medium ${
type === "success"
? "text-success-foreground"
: "text-error-foreground"
}`}
>
{type === "success"
? t("detailsLabel")
: t("errorDetailsLabel")}
</p>
<pre className={`text-xs whitespace-pre-wrap break-words ${
type === 'success'
? 'text-success/80'
: 'text-error/80'
}`}>
<pre
className={`text-xs break-words whitespace-pre-wrap ${
type === "success" ? "text-success/80" : "text-error/80"
}`}
>
{details}
</pre>
</div>
@@ -81,9 +87,9 @@ export function ErrorModal({
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-6 border-t border-border">
<div className="border-border flex justify-end gap-3 border-t p-6">
<Button variant="outline" onClick={onClose}>
{tc('close')}
{tc("close")}
</Button>
</div>
</div>

View File

@@ -1,24 +1,32 @@
'use client';
import { useState, useEffect } from 'react';
import type { Server } from '../../types/server';
import { Button } from './ui/button';
import { ColorCodedDropdown } from './ColorCodedDropdown';
import { SettingsModal } from './SettingsModal';
import { useRegisterModal } from './modal/ModalStackProvider';
import { useTranslation } from '@/lib/i18n/useTranslation';
"use client";
import { useState, useEffect } from "react";
import type { Server } from "../../types/server";
import { Button } from "./ui/button";
import { ColorCodedDropdown } from "./ColorCodedDropdown";
import { SettingsModal } from "./SettingsModal";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useTranslation } from "@/lib/i18n/useTranslation";
interface ExecutionModeModalProps {
isOpen: boolean;
onClose: () => void;
onExecute: (mode: 'local' | 'ssh', server?: Server) => void;
onExecute: (mode: "local" | "ssh", server?: Server) => void;
scriptName: string;
}
export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: ExecutionModeModalProps) {
const { t } = useTranslation('executionModeModal');
useRegisterModal(isOpen, { id: 'execution-mode-modal', allowEscape: true, onClose });
export function ExecutionModeModal({
isOpen,
onClose,
onExecute,
scriptName,
}: ExecutionModeModalProps) {
const { t } = useTranslation("executionModeModal");
useRegisterModal(isOpen, {
id: "execution-mode-modal",
allowEscape: true,
onClose,
});
const [servers, setServers] = useState<Server[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -36,18 +44,18 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
setLoading(true);
setError(null);
try {
const response = await fetch('/api/servers');
const response = await fetch("/api/servers");
if (!response.ok) {
throw new Error(t('errors.fetchFailed'));
throw new Error(t("errors.fetchFailed"));
}
const data = await response.json();
// Sort servers by name alphabetically
const sortedServers = (data as Server[]).sort((a, b) =>
(a.name ?? '').localeCompare(b.name ?? '')
(a.name ?? "").localeCompare(b.name ?? ""),
);
setServers(sortedServers);
} catch (err) {
setError(err instanceof Error ? err.message : t('errors.fetchFailed'));
setError(err instanceof Error ? err.message : t("errors.fetchFailed"));
} finally {
setLoading(false);
}
@@ -57,7 +65,7 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
if (isOpen) {
void fetchServers();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
// Auto-select server when exactly one server is available
@@ -69,37 +77,45 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
const handleExecute = () => {
if (!selectedServer) {
setError(t('errors.noServerSelected'));
setError(t("errors.noServerSelected"));
return;
}
onExecute('ssh', selectedServer);
onExecute("ssh", selectedServer);
onClose();
};
const handleServerSelect = (server: Server | null) => {
setSelectedServer(server);
};
if (!isOpen) return null;
return (
<>
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-xl font-bold text-foreground">{t('title')}</h2>
<div className="border-border flex items-center justify-between border-b p-6">
<h2 className="text-foreground text-xl font-bold">{t("title")}</h2>
<Button
onClick={onClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<svg
className="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</Button>
</div>
@@ -107,60 +123,72 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
{/* Content */}
<div className="p-6">
{error && (
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<div className="bg-destructive/10 border-destructive/20 mb-4 rounded-md border p-3">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-destructive" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
<svg
className="text-destructive h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-destructive">{error}</p>
<p className="text-destructive text-sm">{error}</p>
</div>
</div>
</div>
)}
{loading ? (
<div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<p className="mt-2 text-sm text-muted-foreground">{t('loadingServers')}</p>
<div className="py-8 text-center">
<div className="border-primary inline-block h-8 w-8 animate-spin rounded-full border-b-2"></div>
<p className="text-muted-foreground mt-2 text-sm">
{t("loadingServers")}
</p>
</div>
) : servers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">{t('noServersConfigured')}</p>
<p className="text-xs mt-1">{t('addServersHint')}</p>
<div className="text-muted-foreground py-8 text-center">
<p className="text-sm">{t("noServersConfigured")}</p>
<p className="mt-1 text-xs">{t("addServersHint")}</p>
<Button
onClick={() => setSettingsModalOpen(true)}
variant="outline"
size="sm"
className="mt-3"
>
{t('openServerSettings')}
{t("openServerSettings")}
</Button>
</div>
) : servers.length === 1 ? (
/* Single Server Confirmation View */
<div className="space-y-6">
<div className="text-center">
<h3 className="text-lg font-medium text-foreground mb-2">
{t('installConfirmation.title')}
<h3 className="text-foreground mb-2 text-lg font-medium">
{t("installConfirmation.title")}
</h3>
<p className="text-sm text-muted-foreground">
{t('installConfirmation.description', { values: { scriptName } })}
<p className="text-muted-foreground text-sm">
{t("installConfirmation.description", {
values: { scriptName },
})}
</p>
</div>
<div className="bg-muted/50 rounded-lg p-4 border border-border">
<div className="bg-muted/50 border-border rounded-lg border p-4">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<div className="w-3 h-3 bg-success rounded-full"></div>
<div className="bg-success h-3 w-3 rounded-full"></div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{selectedServer?.name ?? t('unnamedServer')}
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">
{selectedServer?.name ?? t("unnamedServer")}
</p>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
{selectedServer?.ip}
</p>
</div>
@@ -169,19 +197,15 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
{/* Action Buttons */}
<div className="flex justify-end space-x-3">
<Button
onClick={onClose}
variant="outline"
size="default"
>
{t('actions.cancel')}
<Button onClick={onClose} variant="outline" size="default">
{t("actions.cancel")}
</Button>
<Button
onClick={handleExecute}
variant="default"
size="default"
>
{t('actions.install')}
{t("actions.install")}
</Button>
</div>
</div>
@@ -189,41 +213,44 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
/* Multiple Servers Selection View */
<div className="space-y-6">
<div className="mb-6">
<h3 className="text-lg font-medium text-foreground mb-2">
{t('multipleServers.title', { values: { scriptName } })}
<h3 className="text-foreground mb-2 text-lg font-medium">
{t("multipleServers.title", { values: { scriptName } })}
</h3>
</div>
{/* Server Selection */}
<div className="mb-6">
<label htmlFor="server" className="block text-sm font-medium text-foreground mb-2">
{t('multipleServers.selectServerLabel')}
<label
htmlFor="server"
className="text-foreground mb-2 block text-sm font-medium"
>
{t("multipleServers.selectServerLabel")}
</label>
<ColorCodedDropdown
servers={servers}
selectedServer={selectedServer}
onServerSelect={handleServerSelect}
placeholder={t('multipleServers.placeholder')}
placeholder={t("multipleServers.placeholder")}
/>
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-3">
<Button
onClick={onClose}
variant="outline"
size="default"
>
{t('actions.cancel')}
<Button onClick={onClose} variant="outline" size="default">
{t("actions.cancel")}
</Button>
<Button
onClick={handleExecute}
disabled={!selectedServer}
variant="default"
size="default"
className={!selectedServer ? 'bg-muted-foreground cursor-not-allowed' : ''}
className={
!selectedServer
? "bg-muted-foreground cursor-not-allowed"
: ""
}
>
{t('actions.runOnServer')}
{t("actions.runOnServer")}
</Button>
</div>
</div>

View File

@@ -1,34 +1,34 @@
'use client';
"use client";
import { useState } from 'react';
import { HelpModal } from './HelpModal';
import { Button } from './ui/button';
import { HelpCircle } from 'lucide-react';
import { useTranslation } from '@/lib/i18n/useTranslation';
import { useState } from "react";
import { HelpModal } from "./HelpModal";
import { Button } from "./ui/button";
import { HelpCircle } from "lucide-react";
import { useTranslation } from "@/lib/i18n/useTranslation";
interface HelpButtonProps {
initialSection?: string;
}
export function HelpButton({ initialSection }: HelpButtonProps) {
const { t } = useTranslation('helpButton');
const { t } = useTranslation("helpButton");
const [isOpen, setIsOpen] = useState(false);
return (
<>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-muted-foreground font-medium">
{t('needHelp')}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="text-muted-foreground text-sm font-medium">
{t("needHelp")}
</div>
<Button
onClick={() => setIsOpen(true)}
variant="outline"
size="default"
className="inline-flex items-center"
title={t('openHelp')}
title={t("openHelp")}
>
<HelpCircle className="w-5 h-5 mr-2" />
{t('help')}
<HelpCircle className="mr-2 h-5 w-5" />
{t("help")}
</Button>
</div>

View File

@@ -1,8 +1,8 @@
'use client';
"use client";
import { Loader2 } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import { useTranslation } from '@/lib/i18n/useTranslation';
import { Loader2 } from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useTranslation } from "@/lib/i18n/useTranslation";
interface LoadingModalProps {
isOpen: boolean;
@@ -10,27 +10,29 @@ interface LoadingModalProps {
}
export function LoadingModal({ isOpen, action }: LoadingModalProps) {
const { t } = useTranslation('loadingModal');
useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: false, onClose: () => null });
const { t } = useTranslation("loadingModal");
useRegisterModal(isOpen, {
id: "loading-modal",
allowEscape: false,
onClose: () => null,
});
if (!isOpen) return null;
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border p-8">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border w-full max-w-md rounded-lg border p-8 shadow-xl">
<div className="flex flex-col items-center space-y-4">
<div className="relative">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
<Loader2 className="text-primary h-12 w-12 animate-spin" />
<div className="border-primary/20 absolute inset-0 animate-pulse rounded-full border-2"></div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-card-foreground mb-2">
{t('processing')}
<h3 className="text-card-foreground mb-2 text-lg font-semibold">
{t("processing")}
</h3>
<p className="text-sm text-muted-foreground">
{action}
</p>
<p className="text-xs text-muted-foreground mt-2">
{t('pleaseWait')}
<p className="text-muted-foreground text-sm">{action}</p>
<p className="text-muted-foreground mt-2 text-xs">
{t("pleaseWait")}
</p>
</div>
</div>
@@ -38,4 +40,3 @@ export function LoadingModal({ isOpen, action }: LoadingModalProps) {
</div>
);
}

View File

@@ -1,10 +1,10 @@
'use client';
"use client";
import { useState } from 'react';
import { X, Copy, Check, Server, Globe } from 'lucide-react';
import { Button } from './ui/button';
import { useRegisterModal } from './modal/ModalStackProvider';
import { useTranslation } from '@/lib/i18n/useTranslation';
import { useState } from "react";
import { X, Copy, Check, Server, Globe } from "lucide-react";
import { Button } from "./ui/button";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useTranslation } from "@/lib/i18n/useTranslation";
interface PublicKeyModalProps {
isOpen: boolean;
@@ -14,9 +14,19 @@ interface PublicKeyModalProps {
serverIp: string;
}
export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverIp }: PublicKeyModalProps) {
const { t } = useTranslation('publicKeyModal');
useRegisterModal(isOpen, { id: 'public-key-modal', allowEscape: true, onClose });
export function PublicKeyModal({
isOpen,
onClose,
publicKey,
serverName,
serverIp,
}: PublicKeyModalProps) {
const { t } = useTranslation("publicKeyModal");
useRegisterModal(isOpen, {
id: "public-key-modal",
allowEscape: true,
onClose,
});
const [copied, setCopied] = useState(false);
const [commandCopied, setCommandCopied] = useState(false);
@@ -31,31 +41,31 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
setTimeout(() => setCopied(false), 2000);
} else {
// Fallback for older browsers or non-HTTPS
const textArea = document.createElement('textarea');
const textArea = document.createElement("textarea");
textArea.value = publicKey;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
textArea.style.top = "-999999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
document.execCommand("copy");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (fallbackError) {
console.error('Fallback copy failed:', fallbackError);
console.error("Fallback copy failed:", fallbackError);
// If all else fails, show the key in an alert
alert(t('copyFallback') + publicKey);
alert(t("copyFallback") + publicKey);
}
document.body.removeChild(textArea);
}
} catch (error) {
console.error('Failed to copy to clipboard:', error);
console.error("Failed to copy to clipboard:", error);
// Fallback: show the key in an alert
alert(t('copyFallback') + publicKey);
alert(t("copyFallback") + publicKey);
}
};
@@ -69,44 +79,46 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
setTimeout(() => setCommandCopied(false), 2000);
} else {
// Fallback for older browsers or non-HTTPS
const textArea = document.createElement('textarea');
const textArea = document.createElement("textarea");
textArea.value = command;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
textArea.style.top = "-999999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
document.execCommand("copy");
setCommandCopied(true);
setTimeout(() => setCommandCopied(false), 2000);
} catch (fallbackError) {
console.error('Fallback copy failed:', fallbackError);
alert(t('copyCommandFallback') + command);
console.error("Fallback copy failed:", fallbackError);
alert(t("copyCommandFallback") + command);
}
document.body.removeChild(textArea);
}
} catch (error) {
console.error('Failed to copy command to clipboard:', error);
alert(t('copyCommandFallback') + command);
console.error("Failed to copy command to clipboard:", error);
alert(t("copyCommandFallback") + command);
}
};
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border w-full max-w-2xl rounded-lg border shadow-xl">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="border-border flex items-center justify-between border-b p-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-info/10 rounded-lg">
<Server className="h-6 w-6 text-info" />
<div className="bg-info/10 rounded-lg p-2">
<Server className="text-info h-6 w-6" />
</div>
<div>
<h2 className="text-xl font-semibold text-card-foreground">{t('title')}</h2>
<p className="text-sm text-muted-foreground">{t('subtitle')}</p>
<h2 className="text-card-foreground text-xl font-semibold">
{t("title")}
</h2>
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
</div>
</div>
<Button
@@ -120,14 +132,14 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
</div>
{/* Content */}
<div className="p-6 space-y-6">
<div className="space-y-6 p-6">
{/* Server Info */}
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="bg-muted/50 flex items-center gap-4 rounded-lg p-4">
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Server className="h-4 w-4" />
<span className="font-medium">{serverName}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Globe className="h-4 w-4" />
<span>{serverIp}</span>
</div>
@@ -135,19 +147,39 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
{/* Instructions */}
<div className="space-y-2">
<h3 className="font-medium text-foreground">{t('instructions.title')}</h3>
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
<li>{t('instructions.step1')}</li>
<li>{t('instructions.step2')} <code className="bg-muted px-1 rounded">ssh root@{serverIp}</code></li>
<li>{t('instructions.step3')} <code className="bg-muted px-1 rounded">echo &quot;&lt;paste-key&gt;&quot; &gt;&gt; ~/.ssh/authorized_keys</code></li>
<li>{t('instructions.step4')} <code className="bg-muted px-1 rounded">chmod 600 ~/.ssh/authorized_keys</code></li>
<h3 className="text-foreground font-medium">
{t("instructions.title")}
</h3>
<ol className="text-muted-foreground list-inside list-decimal space-y-1 text-sm">
<li>{t("instructions.step1")}</li>
<li>
{t("instructions.step2")}{" "}
<code className="bg-muted rounded px-1">
ssh root@{serverIp}
</code>
</li>
<li>
{t("instructions.step3")}{" "}
<code className="bg-muted rounded px-1">
echo &quot;&lt;paste-key&gt;&quot; &gt;&gt;
~/.ssh/authorized_keys
</code>
</li>
<li>
{t("instructions.step4")}{" "}
<code className="bg-muted rounded px-1">
chmod 600 ~/.ssh/authorized_keys
</code>
</li>
</ol>
</div>
{/* Public Key */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">{t('publicKeyLabel')}</label>
<label className="text-foreground text-sm font-medium">
{t("publicKeyLabel")}
</label>
<Button
variant="outline"
size="sm"
@@ -157,12 +189,12 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
{copied ? (
<>
<Check className="h-4 w-4" />
{t('actions.copied')}
{t("actions.copied")}
</>
) : (
<>
<Copy className="h-4 w-4" />
{t('actions.copy')}
{t("actions.copy")}
</>
)}
</Button>
@@ -170,15 +202,17 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
<textarea
value={publicKey}
readOnly
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground font-mono text-xs min-h-[60px] resize-none border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder={t('placeholder')}
className="bg-card text-foreground border-border focus:ring-ring focus:border-ring min-h-[60px] w-full resize-none rounded-md border px-3 py-2 font-mono text-xs shadow-sm focus:ring-2 focus:outline-none"
placeholder={t("placeholder")}
/>
</div>
{/* Quick Command */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">{t('quickCommandLabel')}</label>
<label className="text-foreground text-sm font-medium">
{t("quickCommandLabel")}
</label>
<Button
variant="outline"
size="sm"
@@ -188,30 +222,30 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
{commandCopied ? (
<>
<Check className="h-4 w-4" />
{t('actions.copied')}
{t("actions.copied")}
</>
) : (
<>
<Copy className="h-4 w-4" />
{t('actions.copyCommand')}
{t("actions.copyCommand")}
</>
)}
</Button>
</div>
<div className="p-3 bg-muted/50 rounded-md border border-border">
<code className="text-sm font-mono text-foreground break-all">
<div className="bg-muted/50 border-border rounded-md border p-3">
<code className="text-foreground font-mono text-sm break-all">
echo &quot;{publicKey}&quot; &gt;&gt; ~/.ssh/authorized_keys
</code>
</div>
<p className="text-xs text-muted-foreground">
{t('quickCommandHint')}
<p className="text-muted-foreground text-xs">
{t("quickCommandHint")}
</p>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<div className="border-border flex justify-end gap-3 border-t pt-4">
<Button variant="outline" onClick={onClose}>
{t('actions.close')}
{t("actions.close")}
</Button>
</div>
</div>

View File

@@ -1,13 +1,13 @@
'use client';
"use client";
import { useState } from 'react';
import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { ContextualHelpIcon } from './ContextualHelpIcon';
import { useTranslation } from '@/lib/i18n/useTranslation';
import { useState } from "react";
import { api } from "~/trpc/react";
import { Button } from "./ui/button";
import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { useTranslation } from "@/lib/i18n/useTranslation";
export function ResyncButton() {
const { t } = useTranslation('resyncButton');
const { t } = useTranslation("resyncButton");
const [isResyncing, setIsResyncing] = useState(false);
const [lastSync, setLastSync] = useState<Date | null>(null);
const [syncMessage, setSyncMessage] = useState<string | null>(null);
@@ -17,20 +17,22 @@ export function ResyncButton() {
setIsResyncing(false);
setLastSync(new Date());
if (data.success) {
setSyncMessage(data.message ?? t('messages.success'));
setSyncMessage(data.message ?? t("messages.success"));
// Reload the page after successful sync
setTimeout(() => {
window.location.reload();
}, 2000); // Wait 2 seconds to show the success message
} else {
setSyncMessage(data.error ?? t('messages.failed'));
setSyncMessage(data.error ?? t("messages.failed"));
// Clear message after 3 seconds for errors
setTimeout(() => setSyncMessage(null), 3000);
}
},
onError: (error) => {
setIsResyncing(false);
setSyncMessage(t('messages.error', { values: { message: error.message } }));
setSyncMessage(
t("messages.error", { values: { message: error.message } }),
);
setTimeout(() => setSyncMessage(null), 3000);
},
});
@@ -42,11 +44,11 @@ export function ResyncButton() {
};
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-muted-foreground font-medium">
{t('syncDescription')}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="text-muted-foreground text-sm font-medium">
{t("syncDescription")}
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex items-center gap-2">
<Button
onClick={handleResync}
@@ -57,34 +59,51 @@ export function ResyncButton() {
>
{isResyncing ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
<span>{t('syncing')}</span>
<div className="mr-2 h-5 w-5 animate-spin rounded-full border-b-2 border-white"></div>
<span>{t("syncing")}</span>
</>
) : (
<>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
<svg
className="mr-2 h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>{t('syncJsonFiles')}</span>
<span>{t("syncJsonFiles")}</span>
</>
)}
</Button>
<ContextualHelpIcon section="sync-button" tooltip={t('helpTooltip')} />
<ContextualHelpIcon
section="sync-button"
tooltip={t("helpTooltip")}
/>
</div>
{lastSync && (
<div className="text-xs text-muted-foreground">
{t('lastSync', { values: { time: lastSync.toLocaleTimeString() } })}
<div className="text-muted-foreground text-xs">
{t("lastSync", { values: { time: lastSync.toLocaleTimeString() } })}
</div>
)}
</div>
{syncMessage && (
<div className={`text-sm px-3 py-1 rounded-lg ${
syncMessage.includes('Error') || syncMessage.includes('Failed') || syncMessage.includes('Fehler')
? 'bg-error/10 text-error'
: 'bg-success/10 text-success'
}`}>
<div
className={`rounded-lg px-3 py-1 text-sm ${
syncMessage.includes("Error") ||
syncMessage.includes("Failed") ||
syncMessage.includes("Fehler")
? "bg-error/10 text-error"
: "bg-success/10 text-success"
}`}
>
{syncMessage}
</div>
)}

View File

@@ -1,10 +1,10 @@
'use client';
"use client";
import { useState } from 'react';
import Image from 'next/image';
import type { ScriptCard } from '~/types/script';
import { TypeBadge, UpdateableBadge } from './Badge';
import { useTranslation } from '@/lib/i18n/useTranslation';
import { useState } from "react";
import Image from "next/image";
import type { ScriptCard } from "~/types/script";
import { TypeBadge, UpdateableBadge } from "./Badge";
import { useTranslation } from "@/lib/i18n/useTranslation";
interface ScriptCardProps {
script: ScriptCard;
@@ -13,8 +13,13 @@ interface ScriptCardProps {
onToggleSelect?: (slug: string) => void;
}
export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardProps) {
const { t } = useTranslation('scriptCard');
export function ScriptCard({
script,
onClick,
isSelected = false,
onToggleSelect,
}: ScriptCardProps) {
const { t } = useTranslation("scriptCard");
const [imageError, setImageError] = useState(false);
const handleImageError = () => {
@@ -30,32 +35,36 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
return (
<div
className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col relative"
className="bg-card border-border hover:border-primary relative flex h-full cursor-pointer flex-col rounded-lg border shadow-md transition-shadow duration-200 hover:shadow-lg"
onClick={() => onClick(script)}
>
{/* Checkbox in top-left corner */}
{onToggleSelect && (
<div className="absolute top-2 left-2 z-10">
<div
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
className={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
? "bg-primary border-primary text-primary-foreground"
: "bg-card border-border hover:border-primary/60 hover:bg-accent"
}`}
onClick={handleCheckboxClick}
>
{isSelected && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</div>
</div>
)}
<div className="p-6 flex-1 flex flex-col">
<div className="flex flex-1 flex-col p-6">
{/* Header with logo and name */}
<div className="flex items-start space-x-4 mb-4">
<div className="mb-4 flex items-start space-x-4">
<div className="flex-shrink-0">
{script.logo && !imageError ? (
<Image
@@ -63,37 +72,41 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
alt={`${script.name} logo`}
width={48}
height={48}
className="w-12 h-12 rounded-lg object-contain"
className="h-12 w-12 rounded-lg object-contain"
onError={handleImageError}
/>
) : (
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center">
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
<span className="text-muted-foreground text-lg font-semibold">
{script.name?.charAt(0)?.toUpperCase() || '?'}
{script.name?.charAt(0)?.toUpperCase() || "?"}
</span>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground truncate">
{script.name || t('unnamedScript')}
<div className="min-w-0 flex-1">
<h3 className="text-foreground truncate text-lg font-semibold">
{script.name || t("unnamedScript")}
</h3>
<div className="mt-2 space-y-2">
{/* Type and Updateable status on first row */}
<div className="flex items-center space-x-2 flex-wrap gap-1">
<TypeBadge type={script.type ?? 'unknown'} />
<div className="flex flex-wrap items-center gap-1 space-x-2">
<TypeBadge type={script.type ?? "unknown"} />
{script.updateable && <UpdateableBadge />}
</div>
{/* Download Status */}
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${
script.isDownloaded ? 'bg-success' : 'bg-error'
}`}></div>
<span className={`text-xs font-medium ${
script.isDownloaded ? 'text-success' : 'text-error'
}`}>
{script.isDownloaded ? t('downloaded') : t('notDownloaded')}
<div
className={`h-2 w-2 rounded-full ${
script.isDownloaded ? "bg-success" : "bg-error"
}`}
></div>
<span
className={`text-xs font-medium ${
script.isDownloaded ? "text-success" : "text-error"
}`}
>
{script.isDownloaded ? t("downloaded") : t("notDownloaded")}
</span>
</div>
</div>
@@ -101,8 +114,8 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
</div>
{/* Description */}
<p className="text-muted-foreground text-sm line-clamp-3 mb-4 flex-1">
{script.description || t('noDescription')}
<p className="text-muted-foreground mb-4 line-clamp-3 flex-1 text-sm">
{script.description || t("noDescription")}
</p>
{/* Footer with website link */}
@@ -112,12 +125,22 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
href={script.website}
target="_blank"
rel="noopener noreferrer"
className="text-info hover:text-info/80 text-sm font-medium flex items-center space-x-1"
className="text-info hover:text-info/80 flex items-center space-x-1 text-sm font-medium"
onClick={(e) => e.stopPropagation()}
>
<span>{t('website')}</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
<span>{t("website")}</span>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</div>

View File

@@ -1,29 +1,29 @@
'use client';
"use client";
import { useState } from 'react';
import { SettingsModal } from './SettingsModal';
import { Button } from './ui/button';
import { useTranslation } from '@/lib/i18n/useTranslation';
import { useState } from "react";
import { SettingsModal } from "./SettingsModal";
import { Button } from "./ui/button";
import { useTranslation } from "@/lib/i18n/useTranslation";
export function ServerSettingsButton() {
const { t } = useTranslation('serverSettingsButton');
const { t } = useTranslation("serverSettingsButton");
const [isOpen, setIsOpen] = useState(false);
return (
<>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-muted-foreground font-medium">
{t('description')}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="text-muted-foreground text-sm font-medium">
{t("description")}
</div>
<Button
onClick={() => setIsOpen(true)}
variant="outline"
size="default"
className="inline-flex items-center"
title={t('buttonTitle')}
title={t("buttonTitle")}
>
<svg
className="w-5 h-5 mr-2"
className="mr-2 h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -42,7 +42,7 @@ export function ServerSettingsButton() {
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{t('buttonLabel')}
{t("buttonLabel")}
</Button>
</div>

View File

@@ -1,30 +1,30 @@
'use client';
"use client";
import { useState } from 'react';
import { GeneralSettingsModal } from './GeneralSettingsModal';
import { Button } from './ui/button';
import { Settings } from 'lucide-react';
import { useTranslation } from '@/lib/i18n/useTranslation';
import { useState } from "react";
import { GeneralSettingsModal } from "./GeneralSettingsModal";
import { Button } from "./ui/button";
import { Settings } from "lucide-react";
import { useTranslation } from "@/lib/i18n/useTranslation";
export function SettingsButton() {
const { t } = useTranslation('settingsButton');
const { t } = useTranslation("settingsButton");
const [isOpen, setIsOpen] = useState(false);
return (
<>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-sm text-muted-foreground font-medium">
{t('description')}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="text-muted-foreground text-sm font-medium">
{t("description")}
</div>
<Button
onClick={() => setIsOpen(true)}
variant="outline"
size="default"
className="inline-flex items-center"
title={t('buttonTitle')}
title={t("buttonTitle")}
>
<Settings className="w-5 h-5 mr-2" />
{t('buttonLabel')}
<Settings className="mr-2 h-5 w-5" />
{t("buttonLabel")}
</Button>
</div>

View File

@@ -1,12 +1,12 @@
'use client';
"use client";
import { useState } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Toggle } from './ui/toggle';
import { Lock, User, Shield, AlertCircle } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import { useTranslation } from '@/lib/i18n/useTranslation';
import { useState } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Toggle } from "./ui/toggle";
import { Lock, User, Shield, AlertCircle } from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useTranslation } from "@/lib/i18n/useTranslation";
interface SetupModalProps {
isOpen: boolean;
@@ -14,11 +14,15 @@ interface SetupModalProps {
}
export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
const { t } = useTranslation('setupModal');
useRegisterModal(isOpen, { id: 'setup-modal', allowEscape: true, onClose: () => null });
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const { t } = useTranslation("setupModal");
useRegisterModal(isOpen, {
id: "setup-modal",
allowEscape: true,
onClose: () => null,
});
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [enableAuth, setEnableAuth] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -30,31 +34,31 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
// Only validate passwords if authentication is enabled
if (enableAuth && password !== confirmPassword) {
setError(t('errors.passwordMismatch'));
setError(t("errors.passwordMismatch"));
setIsLoading(false);
return;
}
try {
const response = await fetch('/api/auth/setup', {
method: 'POST',
const response = await fetch("/api/auth/setup", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({
username: enableAuth ? username : undefined,
password: enableAuth ? password : undefined,
enabled: enableAuth
enabled: enableAuth,
}),
});
if (response.ok) {
// If authentication is enabled, automatically log in the user
if (enableAuth) {
const loginResponse = await fetch('/api/auth/login', {
method: 'POST',
const loginResponse = await fetch("/api/auth/login", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password }),
});
@@ -64,7 +68,7 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
onComplete();
} else {
// Setup succeeded but login failed, still complete setup
console.warn('Setup completed but auto-login failed');
console.warn("Setup completed but auto-login failed");
onComplete();
}
} else {
@@ -72,12 +76,12 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
onComplete();
}
} else {
const errorData = await response.json() as { error: string };
setError(errorData.error ?? t('errors.setupFailed'));
const errorData = (await response.json()) as { error: string };
setError(errorData.error ?? t("errors.setupFailed"));
}
} catch (error) {
console.error('Setup error:', error);
setError(t('errors.setupFailed'));
console.error("Setup error:", error);
setError(t("errors.setupFailed"));
}
setIsLoading(false);
@@ -86,105 +90,117 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
{/* Header */}
<div className="flex items-center justify-center p-6 border-b border-border">
<div className="border-border flex items-center justify-center border-b p-6">
<div className="flex items-center gap-3">
<Shield className="h-8 w-8 text-success" />
<h2 className="text-2xl font-bold text-card-foreground">{t('title')}</h2>
<Shield className="text-success h-8 w-8" />
<h2 className="text-card-foreground text-2xl font-bold">
{t("title")}
</h2>
</div>
</div>
{/* Content */}
<div className="p-6">
<p className="text-muted-foreground text-center mb-6">
{t('description')}
<p className="text-muted-foreground mb-6 text-center">
{t("description")}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="setup-username" className="block text-sm font-medium text-foreground mb-2">
{t('username.label')}
<label
htmlFor="setup-username"
className="text-foreground mb-2 block text-sm font-medium"
>
{t("username.label")}
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="setup-username"
type="text"
placeholder={t('username.placeholder')}
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoading}
className="pl-10"
required={enableAuth}
minLength={3}
/>
<User className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
id="setup-username"
type="text"
placeholder={t("username.placeholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoading}
className="pl-10"
required={enableAuth}
minLength={3}
/>
</div>
</div>
<div>
<label htmlFor="setup-password" className="block text-sm font-medium text-foreground mb-2">
{t('password.label')}
<label
htmlFor="setup-password"
className="text-foreground mb-2 block text-sm font-medium"
>
{t("password.label")}
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="setup-password"
type="password"
placeholder={t('password.placeholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
className="pl-10"
required={enableAuth}
minLength={6}
/>
<Lock className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
id="setup-password"
type="password"
placeholder={t("password.placeholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
className="pl-10"
required={enableAuth}
minLength={6}
/>
</div>
</div>
<div>
<label htmlFor="confirm-password" className="block text-sm font-medium text-foreground mb-2">
{t('confirmPassword.label')}
<label
htmlFor="confirm-password"
className="text-foreground mb-2 block text-sm font-medium"
>
{t("confirmPassword.label")}
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="confirm-password"
type="password"
placeholder={t('confirmPassword.placeholder')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
className="pl-10"
required={enableAuth}
minLength={6}
/>
<Lock className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
id="confirm-password"
type="password"
placeholder={t("confirmPassword.placeholder")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
className="pl-10"
required={enableAuth}
minLength={6}
/>
</div>
</div>
<div className="p-4 border border-border rounded-lg bg-muted/30">
<div className="border-border bg-muted/30 rounded-lg border p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-foreground mb-1">{t('enableAuth.title')}</h4>
<p className="text-sm text-muted-foreground">
<h4 className="text-foreground mb-1 font-medium">
{t("enableAuth.title")}
</h4>
<p className="text-muted-foreground text-sm">
{enableAuth
? t('enableAuth.descriptionEnabled')
: t('enableAuth.descriptionDisabled')
}
? t("enableAuth.descriptionEnabled")
: t("enableAuth.descriptionDisabled")}
</p>
</div>
<Toggle
checked={enableAuth}
onCheckedChange={setEnableAuth}
disabled={isLoading}
label={t('enableAuth.label')}
label={t("enableAuth.label")}
/>
</div>
</div>
{error && (
<div className="flex items-center gap-2 p-3 bg-error/10 text-error-foreground border border-error/20 rounded-md">
<div className="bg-error/10 text-error-foreground border-error/20 flex items-center gap-2 rounded-md border p-3">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">{error}</span>
</div>
@@ -194,11 +210,14 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
type="submit"
disabled={
isLoading ||
(enableAuth && (!username.trim() || !password.trim() || !confirmPassword.trim()))
(enableAuth &&
(!username.trim() ||
!password.trim() ||
!confirmPassword.trim()))
}
className="w-full"
>
{isLoading ? t('actions.settingUp') : t('actions.completeSetup')}
{isLoading ? t("actions.settingUp") : t("actions.completeSetup")}
</Button>
</form>
</div>

View File

@@ -1,4 +1,4 @@
'use client';
"use client";
import { api } from "~/trpc/react";
import { Badge } from "./ui/badge";
@@ -16,51 +16,51 @@ interface VersionDisplayProps {
// Loading overlay component with log streaming
function LoadingOverlay({
isNetworkError = false,
logs = []
logs = [],
}: {
isNetworkError?: boolean;
logs?: string[];
}) {
const { t } = useTranslation('versionDisplay.loadingOverlay');
const { t } = useTranslation("versionDisplay.loadingOverlay");
const logsEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new logs arrive
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [logs]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-card rounded-lg p-8 shadow-2xl border border-border max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
<div className="bg-card border-border mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg border p-8 shadow-2xl">
<div className="flex flex-col items-center space-y-4">
<div className="relative">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
<Loader2 className="text-primary h-12 w-12 animate-spin" />
<div className="border-primary/20 absolute inset-0 animate-pulse rounded-full border-2"></div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-card-foreground mb-2">
{isNetworkError ? t('serverRestarting') : t('updatingApplication')}
<h3 className="text-card-foreground mb-2 text-lg font-semibold">
{isNetworkError
? t("serverRestarting")
: t("updatingApplication")}
</h3>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
{isNetworkError
? t('serverRestartingMessage')
: t('updatingMessage')
}
? t("serverRestartingMessage")
: t("updatingMessage")}
</p>
<p className="text-xs text-muted-foreground mt-2">
{isNetworkError
? t('serverRestartingNote')
: t('updatingNote')
}
<p className="text-muted-foreground mt-2 text-xs">
{isNetworkError ? t("serverRestartingNote") : t("updatingNote")}
</p>
</div>
{/* Log output */}
{logs.length > 0 && (
<div className="w-full mt-4 bg-card border border-border rounded-lg p-4 font-mono text-xs text-chart-2 max-h-60 overflow-y-auto terminal-output">
<div className="bg-card border-border text-chart-2 terminal-output mt-4 max-h-60 w-full overflow-y-auto rounded-lg border p-4 font-mono text-xs">
{logs.map((log, index) => (
<div key={index} className="mb-1 whitespace-pre-wrap break-words">
<div
key={index}
className="mb-1 break-words whitespace-pre-wrap"
>
{log}
</div>
))}
@@ -69,9 +69,15 @@ function LoadingOverlay({
)}
<div className="flex space-x-1">
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
<div className="bg-primary h-2 w-2 animate-bounce rounded-full"></div>
<div
className="bg-primary h-2 w-2 animate-bounce rounded-full"
style={{ animationDelay: "0.1s" }}
></div>
<div
className="bg-primary h-2 w-2 animate-bounce rounded-full"
style={{ animationDelay: "0.2s" }}
></div>
</div>
</div>
</div>
@@ -79,12 +85,21 @@ function LoadingOverlay({
);
}
export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) {
const { t } = useTranslation('versionDisplay');
const { t: tOverlay } = useTranslation('versionDisplay.loadingOverlay');
const { data: versionStatus, isLoading, error } = api.version.getVersionStatus.useQuery();
export function VersionDisplay({
onOpenReleaseNotes,
}: VersionDisplayProps = {}) {
const { t } = useTranslation("versionDisplay");
const { t: tOverlay } = useTranslation("versionDisplay.loadingOverlay");
const {
data: versionStatus,
isLoading,
error,
} = api.version.getVersionStatus.useQuery();
const [isUpdating, setIsUpdating] = useState(false);
const [updateResult, setUpdateResult] = useState<{ success: boolean; message: string } | null>(null);
const [updateResult, setUpdateResult] = useState<{
success: boolean;
message: string;
} | null>(null);
const [isNetworkError, setIsNetworkError] = useState(false);
const [updateLogs, setUpdateLogs] = useState<string[]>([]);
const [shouldSubscribe, setShouldSubscribe] = useState(false);
@@ -99,7 +114,7 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
if (result.success) {
// Start subscribing to update logs
setShouldSubscribe(true);
setUpdateLogs([tOverlay('updateStarted')]);
setUpdateLogs([tOverlay("updateStarted")]);
} else {
setIsUpdating(false);
}
@@ -107,29 +122,32 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
onError: (error) => {
setUpdateResult({ success: false, message: error.message });
setIsUpdating(false);
}
},
});
// Poll for update logs
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, {
enabled: shouldSubscribe,
refetchInterval: 1000, // Poll every second
refetchIntervalInBackground: true,
});
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(
undefined,
{
enabled: shouldSubscribe,
refetchInterval: 1000, // Poll every second
refetchIntervalInBackground: true,
},
);
// Attempt to reconnect and reload page when server is back
const startReconnectAttempts = useCallback(() => {
if (reconnectIntervalRef.current) return;
setUpdateLogs(prev => [...prev, tOverlay('reconnecting')]);
setUpdateLogs((prev) => [...prev, tOverlay("reconnecting")]);
reconnectIntervalRef.current = setInterval(() => {
void (async () => {
try {
// Try to fetch the root path to check if server is back
const response = await fetch('/', { method: 'HEAD' });
const response = await fetch("/", { method: "HEAD" });
if (response.ok || response.status === 200) {
setUpdateLogs(prev => [...prev, tOverlay('serverBackOnline')]);
setUpdateLogs((prev) => [...prev, tOverlay("serverBackOnline")]);
// Clear interval and reload
if (reconnectIntervalRef.current) {
@@ -154,7 +172,7 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
setUpdateLogs(updateLogsData.logs);
if (updateLogsData.isComplete) {
setUpdateLogs(prev => [...prev, tOverlay('updateComplete')]);
setUpdateLogs((prev) => [...prev, tOverlay("updateComplete")]);
setIsNetworkError(true);
// Start reconnection attempts when we know update is complete
startReconnectAttempts();
@@ -172,12 +190,18 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
// Only start reconnection if we've been updating for at least 3 minutes
// and no logs for 60 seconds (very conservative fallback)
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
const hasBeenUpdatingLongEnough =
updateStartTime && Date.now() - updateStartTime > 180000; // 3 minutes
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) {
if (
hasBeenUpdatingLongEnough &&
noLogsForAWhile &&
isUpdating &&
!isNetworkError
) {
setIsNetworkError(true);
setUpdateLogs(prev => [...prev, tOverlay('serverRestarting2')]);
setUpdateLogs((prev) => [...prev, tOverlay("serverRestarting2")]);
// Start trying to reconnect
startReconnectAttempts();
@@ -185,7 +209,14 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
}, 10000); // Check every 10 seconds
return () => clearInterval(checkInterval);
}, [shouldSubscribe, isUpdating, updateStartTime, isNetworkError, tOverlay, startReconnectAttempts]);
}, [
shouldSubscribe,
isUpdating,
updateStartTime,
isNetworkError,
tOverlay,
startReconnectAttempts,
]);
// Cleanup reconnect interval on unmount
useEffect(() => {
@@ -211,7 +242,7 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
return (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="animate-pulse">
{t('loading')}
{t("loading")}
</Badge>
</div>
);
@@ -221,66 +252,82 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
return (
<div className="flex items-center gap-2">
<Badge variant="destructive">
v{versionStatus?.currentVersion ?? t('unknownVersion')}
v{versionStatus?.currentVersion ?? t("unknownVersion")}
</Badge>
<span className="text-xs text-muted-foreground">
{t('unableToCheck')}
<span className="text-muted-foreground text-xs">
{t("unableToCheck")}
</span>
</div>
);
}
const { currentVersion, isUpToDate, updateAvailable, releaseInfo } = versionStatus;
const { currentVersion, isUpToDate, updateAvailable, releaseInfo } =
versionStatus;
return (
<>
{/* Loading overlay */}
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
{isUpdating && (
<LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />
)}
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
<div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-2">
<Badge
variant={isUpToDate ? "default" : "secondary"}
className={`text-xs ${onOpenReleaseNotes ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
className={`text-xs ${onOpenReleaseNotes ? "cursor-pointer transition-opacity hover:opacity-80" : ""}`}
onClick={onOpenReleaseNotes}
>
v{currentVersion}
</Badge>
{updateAvailable && releaseInfo && (
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-3">
<div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-3">
<div className="flex items-center gap-2">
<Button
onClick={handleUpdate}
disabled={isUpdating}
size="sm"
variant="destructive"
className="text-xs h-6 px-2"
className="h-6 px-2 text-xs"
>
{isUpdating ? (
<>
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
<span className="hidden sm:inline">{t('update.updating')}</span>
<span className="sm:hidden">{t('update.updatingShort')}</span>
<RefreshCw className="mr-1 h-3 w-3 animate-spin" />
<span className="hidden sm:inline">
{t("update.updating")}
</span>
<span className="sm:hidden">
{t("update.updatingShort")}
</span>
</>
) : (
<>
<Download className="h-3 w-3 mr-1" />
<span className="hidden sm:inline">{t('update.updateNow')}</span>
<span className="sm:hidden">{t('update.updateNowShort')}</span>
<Download className="mr-1 h-3 w-3" />
<span className="hidden sm:inline">
{t("update.updateNow")}
</span>
<span className="sm:hidden">
{t("update.updateNowShort")}
</span>
</>
)}
</Button>
<ContextualHelpIcon section="update-system" tooltip={t('helpTooltip')} />
<ContextualHelpIcon
section="update-system"
tooltip={t("helpTooltip")}
/>
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">{t('releaseNotes')}</span>
<span className="text-muted-foreground text-xs">
{t("releaseNotes")}
</span>
<a
href={releaseInfo.htmlUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs transition-colors"
title="View latest release"
>
<ExternalLink className="h-3 w-3" />
@@ -288,11 +335,13 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
</div>
{updateResult && (
<div className={`text-xs px-2 py-1 rounded text-center ${
updateResult.success
? 'bg-chart-2/20 text-chart-2 border border-chart-2/30'
: 'bg-destructive/20 text-destructive border border-destructive/30'
}`}>
<div
className={`rounded px-2 py-1 text-center text-xs ${
updateResult.success
? "bg-chart-2/20 text-chart-2 border-chart-2/30 border"
: "bg-destructive/20 text-destructive border-destructive/30 border"
}`}
>
{updateResult.message}
</div>
)}
@@ -300,9 +349,7 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
)}
{isUpToDate && (
<span className="text-xs text-chart-2">
{t('upToDate')}
</span>
<span className="text-chart-2 text-xs">{t("upToDate")}</span>
)}
</div>
</>

View File

@@ -1,46 +1,46 @@
'use client';
"use client";
import React from 'react';
import { Button } from './ui/button';
import { Grid3X3, List } from 'lucide-react';
import { useTranslation } from '@/lib/i18n/useTranslation';
import React from "react";
import { Button } from "./ui/button";
import { Grid3X3, List } from "lucide-react";
import { useTranslation } from "@/lib/i18n/useTranslation";
interface ViewToggleProps {
viewMode: 'card' | 'list';
onViewModeChange: (mode: 'card' | 'list') => void;
viewMode: "card" | "list";
onViewModeChange: (mode: "card" | "list") => void;
}
export function ViewToggle({ viewMode, onViewModeChange }: ViewToggleProps) {
const { t } = useTranslation('viewToggle');
const { t } = useTranslation("viewToggle");
return (
<div className="flex justify-center mb-6">
<div className="flex items-center space-x-1 bg-muted rounded-lg p-1">
<div className="mb-6 flex justify-center">
<div className="bg-muted flex items-center space-x-1 rounded-lg p-1">
<Button
onClick={() => onViewModeChange('card')}
variant={viewMode === 'card' ? 'default' : 'ghost'}
onClick={() => onViewModeChange("card")}
variant={viewMode === "card" ? "default" : "ghost"}
size="sm"
className={`flex items-center space-x-2 ${
viewMode === 'card'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
viewMode === "card"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Grid3X3 className="h-4 w-4" />
<span className="text-sm">{t('cardView')}</span>
<span className="text-sm">{t("cardView")}</span>
</Button>
<Button
onClick={() => onViewModeChange('list')}
variant={viewMode === 'list' ? 'default' : 'ghost'}
onClick={() => onViewModeChange("list")}
variant={viewMode === "list" ? "default" : "ghost"}
size="sm"
className={`flex items-center space-x-2 ${
viewMode === 'list'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
viewMode === "list"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<List className="h-4 w-4" />
<span className="text-sm">{t('listView')}</span>
<span className="text-sm">{t("listView")}</span>
</Button>
</div>
</div>