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

View File

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

View File

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

View File

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

View File

@@ -1,24 +1,32 @@
'use client'; "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';
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 { interface ExecutionModeModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onExecute: (mode: 'local' | 'ssh', server?: Server) => void; onExecute: (mode: "local" | "ssh", server?: Server) => void;
scriptName: string; scriptName: string;
} }
export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: ExecutionModeModalProps) { export function ExecutionModeModal({
const { t } = useTranslation('executionModeModal'); isOpen,
useRegisterModal(isOpen, { id: 'execution-mode-modal', allowEscape: true, onClose }); onClose,
onExecute,
scriptName,
}: ExecutionModeModalProps) {
const { t } = useTranslation("executionModeModal");
useRegisterModal(isOpen, {
id: "execution-mode-modal",
allowEscape: true,
onClose,
});
const [servers, setServers] = useState<Server[]>([]); const [servers, setServers] = useState<Server[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -36,18 +44,18 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await fetch('/api/servers'); const response = await fetch("/api/servers");
if (!response.ok) { if (!response.ok) {
throw new Error(t('errors.fetchFailed')); throw new Error(t("errors.fetchFailed"));
} }
const data = await response.json(); const data = await response.json();
// Sort servers by name alphabetically // Sort servers by name alphabetically
const sortedServers = (data as Server[]).sort((a, b) => const sortedServers = (data as Server[]).sort((a, b) =>
(a.name ?? '').localeCompare(b.name ?? '') (a.name ?? "").localeCompare(b.name ?? ""),
); );
setServers(sortedServers); setServers(sortedServers);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : t('errors.fetchFailed')); setError(err instanceof Error ? err.message : t("errors.fetchFailed"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -69,37 +77,45 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
const handleExecute = () => { const handleExecute = () => {
if (!selectedServer) { if (!selectedServer) {
setError(t('errors.noServerSelected')); setError(t("errors.noServerSelected"));
return; return;
} }
onExecute('ssh', selectedServer); onExecute("ssh", selectedServer);
onClose(); onClose();
}; };
const handleServerSelect = (server: Server | null) => { const handleServerSelect = (server: Server | null) => {
setSelectedServer(server); setSelectedServer(server);
}; };
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<> <>
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border"> <div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
{/* Header */} {/* 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">
<h2 className="text-xl font-bold text-foreground">{t('title')}</h2> <h2 className="text-foreground text-xl font-bold">{t("title")}</h2>
<Button <Button
onClick={onClose} onClick={onClose}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> 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> </svg>
</Button> </Button>
</div> </div>
@@ -107,60 +123,72 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">
{error && ( {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">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<svg className="h-5 w-5 text-destructive" viewBox="0 0 20 20" fill="currentColor"> <svg
<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" /> 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> </svg>
</div> </div>
<div className="ml-3"> <div className="ml-3">
<p className="text-sm text-destructive">{error}</p> <p className="text-destructive text-sm">{error}</p>
</div> </div>
</div> </div>
</div> </div>
)} )}
{loading ? ( {loading ? (
<div className="text-center py-8"> <div className="py-8 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> <div className="border-primary inline-block h-8 w-8 animate-spin rounded-full border-b-2"></div>
<p className="mt-2 text-sm text-muted-foreground">{t('loadingServers')}</p> <p className="text-muted-foreground mt-2 text-sm">
{t("loadingServers")}
</p>
</div> </div>
) : servers.length === 0 ? ( ) : servers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-muted-foreground py-8 text-center">
<p className="text-sm">{t('noServersConfigured')}</p> <p className="text-sm">{t("noServersConfigured")}</p>
<p className="text-xs mt-1">{t('addServersHint')}</p> <p className="mt-1 text-xs">{t("addServersHint")}</p>
<Button <Button
onClick={() => setSettingsModalOpen(true)} onClick={() => setSettingsModalOpen(true)}
variant="outline" variant="outline"
size="sm" size="sm"
className="mt-3" className="mt-3"
> >
{t('openServerSettings')} {t("openServerSettings")}
</Button> </Button>
</div> </div>
) : servers.length === 1 ? ( ) : servers.length === 1 ? (
/* Single Server Confirmation View */ /* Single Server Confirmation View */
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-medium text-foreground mb-2"> <h3 className="text-foreground mb-2 text-lg font-medium">
{t('installConfirmation.title')} {t("installConfirmation.title")}
</h3> </h3>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
{t('installConfirmation.description', { values: { scriptName } })} {t("installConfirmation.description", {
values: { scriptName },
})}
</p> </p>
</div> </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 items-center space-x-3">
<div className="flex-shrink-0"> <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>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate"> <p className="text-foreground truncate text-sm font-medium">
{selectedServer?.name ?? t('unnamedServer')} {selectedServer?.name ?? t("unnamedServer")}
</p> </p>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
{selectedServer?.ip} {selectedServer?.ip}
</p> </p>
</div> </div>
@@ -169,19 +197,15 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex justify-end space-x-3"> <div className="flex justify-end space-x-3">
<Button <Button onClick={onClose} variant="outline" size="default">
onClick={onClose} {t("actions.cancel")}
variant="outline"
size="default"
>
{t('actions.cancel')}
</Button> </Button>
<Button <Button
onClick={handleExecute} onClick={handleExecute}
variant="default" variant="default"
size="default" size="default"
> >
{t('actions.install')} {t("actions.install")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -189,41 +213,44 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
/* Multiple Servers Selection View */ /* Multiple Servers Selection View */
<div className="space-y-6"> <div className="space-y-6">
<div className="mb-6"> <div className="mb-6">
<h3 className="text-lg font-medium text-foreground mb-2"> <h3 className="text-foreground mb-2 text-lg font-medium">
{t('multipleServers.title', { values: { scriptName } })} {t("multipleServers.title", { values: { scriptName } })}
</h3> </h3>
</div> </div>
{/* Server Selection */} {/* Server Selection */}
<div className="mb-6"> <div className="mb-6">
<label htmlFor="server" className="block text-sm font-medium text-foreground mb-2"> <label
{t('multipleServers.selectServerLabel')} htmlFor="server"
className="text-foreground mb-2 block text-sm font-medium"
>
{t("multipleServers.selectServerLabel")}
</label> </label>
<ColorCodedDropdown <ColorCodedDropdown
servers={servers} servers={servers}
selectedServer={selectedServer} selectedServer={selectedServer}
onServerSelect={handleServerSelect} onServerSelect={handleServerSelect}
placeholder={t('multipleServers.placeholder')} placeholder={t("multipleServers.placeholder")}
/> />
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex justify-end space-x-3"> <div className="flex justify-end space-x-3">
<Button <Button onClick={onClose} variant="outline" size="default">
onClick={onClose} {t("actions.cancel")}
variant="outline"
size="default"
>
{t('actions.cancel')}
</Button> </Button>
<Button <Button
onClick={handleExecute} onClick={handleExecute}
disabled={!selectedServer} disabled={!selectedServer}
variant="default" variant="default"
size="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> </Button>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import Image from 'next/image'; import Image from "next/image";
import type { ScriptCard } from '~/types/script'; import type { ScriptCard } from "~/types/script";
import { TypeBadge, UpdateableBadge } from './Badge'; import { TypeBadge, UpdateableBadge } from "./Badge";
import { useTranslation } from '@/lib/i18n/useTranslation'; import { useTranslation } from "@/lib/i18n/useTranslation";
interface ScriptCardProps { interface ScriptCardProps {
script: ScriptCard; script: ScriptCard;
@@ -13,8 +13,13 @@ interface ScriptCardProps {
onToggleSelect?: (slug: string) => void; onToggleSelect?: (slug: string) => void;
} }
export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardProps) { export function ScriptCard({
const { t } = useTranslation('scriptCard'); script,
onClick,
isSelected = false,
onToggleSelect,
}: ScriptCardProps) {
const { t } = useTranslation("scriptCard");
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const handleImageError = () => { const handleImageError = () => {
@@ -30,32 +35,36 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
return ( return (
<div <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)} onClick={() => onClick(script)}
> >
{/* Checkbox in top-left corner */} {/* Checkbox in top-left corner */}
{onToggleSelect && ( {onToggleSelect && (
<div className="absolute top-2 left-2 z-10"> <div className="absolute top-2 left-2 z-10">
<div <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 isSelected
? 'bg-primary border-primary text-primary-foreground' ? "bg-primary border-primary text-primary-foreground"
: 'bg-card border-border hover:border-primary/60 hover:bg-accent' : "bg-card border-border hover:border-primary/60 hover:bg-accent"
}`} }`}
onClick={handleCheckboxClick} onClick={handleCheckboxClick}
> >
{isSelected && ( {isSelected && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"> <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" /> <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> </svg>
)} )}
</div> </div>
</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 */} {/* 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"> <div className="flex-shrink-0">
{script.logo && !imageError ? ( {script.logo && !imageError ? (
<Image <Image
@@ -63,37 +72,41 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
alt={`${script.name} logo`} alt={`${script.name} logo`}
width={48} width={48}
height={48} height={48}
className="w-12 h-12 rounded-lg object-contain" className="h-12 w-12 rounded-lg object-contain"
onError={handleImageError} 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"> <span className="text-muted-foreground text-lg font-semibold">
{script.name?.charAt(0)?.toUpperCase() || '?'} {script.name?.charAt(0)?.toUpperCase() || "?"}
</span> </span>
</div> </div>
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<h3 className="text-lg font-semibold text-foreground truncate"> <h3 className="text-foreground truncate text-lg font-semibold">
{script.name || t('unnamedScript')} {script.name || t("unnamedScript")}
</h3> </h3>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{/* Type and Updateable status on first row */} {/* Type and Updateable status on first row */}
<div className="flex items-center space-x-2 flex-wrap gap-1"> <div className="flex flex-wrap items-center gap-1 space-x-2">
<TypeBadge type={script.type ?? 'unknown'} /> <TypeBadge type={script.type ?? "unknown"} />
{script.updateable && <UpdateableBadge />} {script.updateable && <UpdateableBadge />}
</div> </div>
{/* Download Status */} {/* Download Status */}
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${ <div
script.isDownloaded ? 'bg-success' : 'bg-error' className={`h-2 w-2 rounded-full ${
}`}></div> script.isDownloaded ? "bg-success" : "bg-error"
<span className={`text-xs font-medium ${ }`}
script.isDownloaded ? 'text-success' : 'text-error' ></div>
}`}> <span
{script.isDownloaded ? t('downloaded') : t('notDownloaded')} className={`text-xs font-medium ${
script.isDownloaded ? "text-success" : "text-error"
}`}
>
{script.isDownloaded ? t("downloaded") : t("notDownloaded")}
</span> </span>
</div> </div>
</div> </div>
@@ -101,8 +114,8 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
</div> </div>
{/* Description */} {/* Description */}
<p className="text-muted-foreground text-sm line-clamp-3 mb-4 flex-1"> <p className="text-muted-foreground mb-4 line-clamp-3 flex-1 text-sm">
{script.description || t('noDescription')} {script.description || t("noDescription")}
</p> </p>
{/* Footer with website link */} {/* Footer with website link */}
@@ -112,12 +125,22 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
href={script.website} href={script.website}
target="_blank" target="_blank"
rel="noopener noreferrer" 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()} onClick={(e) => e.stopPropagation()}
> >
<span>{t('website')}</span> <span>{t("website")}</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<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" /> 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> </svg>
</a> </a>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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