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:
@@ -1,23 +1,27 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { useAuth } from './AuthProvider';
|
||||
import { Lock, User, AlertCircle } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { useTranslation } from '@/lib/i18n/useTranslation';
|
||||
import { useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { useAuth } from "./AuthProvider";
|
||||
import { Lock, User, AlertCircle } from "lucide-react";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
import { useTranslation } from "@/lib/i18n/useTranslation";
|
||||
|
||||
interface AuthModalProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export function AuthModal({ isOpen }: AuthModalProps) {
|
||||
const { t } = useTranslation('authModal');
|
||||
useRegisterModal(isOpen, { id: 'auth-modal', allowEscape: false, onClose: () => null });
|
||||
const { t } = useTranslation("authModal");
|
||||
useRegisterModal(isOpen, {
|
||||
id: "auth-modal",
|
||||
allowEscape: false,
|
||||
onClose: () => null,
|
||||
});
|
||||
const { login } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -29,7 +33,7 @@ export function AuthModal({ isOpen }: AuthModalProps) {
|
||||
const success = await login(username, password);
|
||||
|
||||
if (!success) {
|
||||
setError(t('error'));
|
||||
setError(t("error"));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
@@ -38,33 +42,38 @@ export function AuthModal({ isOpen }: AuthModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||
<div className="border-border flex items-center justify-center border-b p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Lock className="h-8 w-8 text-primary" />
|
||||
<h2 className="text-2xl font-bold text-card-foreground">{t('title')}</h2>
|
||||
<Lock className="text-primary h-8 w-8" />
|
||||
<h2 className="text-card-foreground text-2xl font-bold">
|
||||
{t("title")}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<p className="text-muted-foreground text-center mb-6">
|
||||
{t('description')}
|
||||
<p className="text-muted-foreground mb-6 text-center">
|
||||
{t("description")}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('username.label')}
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="text-foreground mb-2 block text-sm font-medium"
|
||||
>
|
||||
{t("username.label")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<User className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder={t('username.placeholder')}
|
||||
placeholder={t("username.placeholder")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={isLoading}
|
||||
@@ -75,15 +84,18 @@ export function AuthModal({ isOpen }: AuthModalProps) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('password.label')}
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="text-foreground mb-2 block text-sm font-medium"
|
||||
>
|
||||
{t("password.label")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Lock className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder={t('password.placeholder')}
|
||||
placeholder={t("password.placeholder")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
@@ -94,7 +106,7 @@ export function AuthModal({ isOpen }: AuthModalProps) {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-error/10 text-error-foreground border border-error/20 rounded-md">
|
||||
<div className="bg-error/10 text-error-foreground border-error/20 flex items-center gap-2 rounded-md border p-3">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
@@ -105,7 +117,7 @@ export function AuthModal({ isOpen }: AuthModalProps) {
|
||||
disabled={isLoading || !username.trim() || !password.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? t('actions.signingIn') : t('actions.signIn')}
|
||||
{isLoading ? t("actions.signingIn") : t("actions.signIn")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,94 +1,108 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from '@/lib/i18n/useTranslation';
|
||||
import React from "react";
|
||||
import { useTranslation } from "@/lib/i18n/useTranslation";
|
||||
|
||||
interface BadgeProps {
|
||||
variant: 'type' | 'updateable' | 'privileged' | 'status' | 'note' | 'execution-mode';
|
||||
variant:
|
||||
| "type"
|
||||
| "updateable"
|
||||
| "privileged"
|
||||
| "status"
|
||||
| "note"
|
||||
| "execution-mode";
|
||||
type?: string;
|
||||
noteType?: 'info' | 'warning' | 'error';
|
||||
status?: 'success' | 'failed' | 'in_progress';
|
||||
executionMode?: 'local' | 'ssh';
|
||||
noteType?: "info" | "warning" | "error";
|
||||
status?: "success" | "failed" | "in_progress";
|
||||
executionMode?: "local" | "ssh";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Badge({ variant, type, noteType, status, executionMode, children, className = '' }: BadgeProps) {
|
||||
export function Badge({
|
||||
variant,
|
||||
type,
|
||||
noteType,
|
||||
status,
|
||||
executionMode,
|
||||
children,
|
||||
className = "",
|
||||
}: BadgeProps) {
|
||||
const getTypeStyles = (scriptType: string) => {
|
||||
switch (scriptType.toLowerCase()) {
|
||||
case 'ct':
|
||||
return 'bg-primary/10 text-primary border-primary/20';
|
||||
case 'addon':
|
||||
return 'bg-primary/10 text-primary border-primary/20';
|
||||
case 'vm':
|
||||
return 'bg-success/10 text-success border-success/20';
|
||||
case 'pve':
|
||||
return 'bg-warning/10 text-warning border-warning/20';
|
||||
case "ct":
|
||||
return "bg-primary/10 text-primary border-primary/20";
|
||||
case "addon":
|
||||
return "bg-primary/10 text-primary border-primary/20";
|
||||
case "vm":
|
||||
return "bg-success/10 text-success border-success/20";
|
||||
case "pve":
|
||||
return "bg-warning/10 text-warning border-warning/20";
|
||||
default:
|
||||
return 'bg-muted text-muted-foreground border-border';
|
||||
return "bg-muted text-muted-foreground border-border";
|
||||
}
|
||||
};
|
||||
|
||||
const getVariantStyles = () => {
|
||||
switch (variant) {
|
||||
case 'type':
|
||||
return `inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${type ? getTypeStyles(type) : getTypeStyles('unknown')}`;
|
||||
case "type":
|
||||
return `inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${type ? getTypeStyles(type) : getTypeStyles("unknown")}`;
|
||||
|
||||
case 'updateable':
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20';
|
||||
case "updateable":
|
||||
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20";
|
||||
|
||||
case 'privileged':
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20';
|
||||
case "privileged":
|
||||
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20";
|
||||
|
||||
case 'status':
|
||||
case "status":
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20';
|
||||
case 'failed':
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-error/10 text-error border border-error/20';
|
||||
case 'in_progress':
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning border border-warning/20';
|
||||
case "success":
|
||||
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20";
|
||||
case "failed":
|
||||
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-error/10 text-error border border-error/20";
|
||||
case "in_progress":
|
||||
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning border border-warning/20";
|
||||
default:
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border';
|
||||
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border";
|
||||
}
|
||||
|
||||
case 'execution-mode':
|
||||
case "execution-mode":
|
||||
switch (executionMode) {
|
||||
case 'local':
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20';
|
||||
case 'ssh':
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20';
|
||||
case "local":
|
||||
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20";
|
||||
case "ssh":
|
||||
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20";
|
||||
default:
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border';
|
||||
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border";
|
||||
}
|
||||
|
||||
case 'note':
|
||||
case "note":
|
||||
switch (noteType) {
|
||||
case 'warning':
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning border border-warning/20';
|
||||
case 'error':
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20';
|
||||
case "warning":
|
||||
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning border border-warning/20";
|
||||
case "error":
|
||||
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20";
|
||||
default:
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20';
|
||||
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20";
|
||||
}
|
||||
|
||||
default:
|
||||
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border';
|
||||
return "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border";
|
||||
}
|
||||
};
|
||||
|
||||
// Format the text for type badges
|
||||
const formatText = () => {
|
||||
if (variant === 'type' && type) {
|
||||
if (variant === "type" && type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'ct':
|
||||
return 'LXC';
|
||||
case 'addon':
|
||||
return 'ADDON';
|
||||
case 'vm':
|
||||
return 'VM';
|
||||
case 'pve':
|
||||
return 'PVE';
|
||||
case "ct":
|
||||
return "LXC";
|
||||
case "addon":
|
||||
return "ADDON";
|
||||
case "vm":
|
||||
return "VM";
|
||||
case "pve":
|
||||
return "PVE";
|
||||
default:
|
||||
return type.toUpperCase();
|
||||
}
|
||||
@@ -97,50 +111,78 @@ export function Badge({ variant, type, noteType, status, executionMode, children
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`${getVariantStyles()} ${className}`}>
|
||||
{formatText()}
|
||||
</span>
|
||||
<span className={`${getVariantStyles()} ${className}`}>{formatText()}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Convenience components for common use cases
|
||||
export const TypeBadge = ({ type, className }: { type: string; className?: string }) => (
|
||||
export const TypeBadge = ({
|
||||
type,
|
||||
className,
|
||||
}: {
|
||||
type: string;
|
||||
className?: string;
|
||||
}) => (
|
||||
<Badge variant="type" type={type} className={className}>
|
||||
{type}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
export const UpdateableBadge = ({ className }: { className?: string }) => {
|
||||
const { t } = useTranslation('badge');
|
||||
const { t } = useTranslation("badge");
|
||||
return (
|
||||
<Badge variant="updateable" className={className}>
|
||||
{t('updateable')}
|
||||
{t("updateable")}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const PrivilegedBadge = ({ className }: { className?: string }) => {
|
||||
const { t } = useTranslation('badge');
|
||||
const { t } = useTranslation("badge");
|
||||
return (
|
||||
<Badge variant="privileged" className={className}>
|
||||
{t('privileged')}
|
||||
{t("privileged")}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatusBadge = ({ status, children, className }: { status: 'success' | 'failed' | 'in_progress'; children: React.ReactNode; className?: string }) => (
|
||||
export const StatusBadge = ({
|
||||
status,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
status: "success" | "failed" | "in_progress";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<Badge variant="status" status={status} className={className}>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
export const ExecutionModeBadge = ({ mode, children, className }: { mode: 'local' | 'ssh'; children: React.ReactNode; className?: string }) => (
|
||||
export const ExecutionModeBadge = ({
|
||||
mode,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
mode: "local" | "ssh";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<Badge variant="execution-mode" executionMode={mode} className={className}>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
export const NoteBadge = ({ noteType, children, className }: { noteType: 'info' | 'warning' | 'error'; children: React.ReactNode; className?: string }) => (
|
||||
export const NoteBadge = ({
|
||||
noteType,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
noteType: "info" | "warning" | "error";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<Badge variant="note" noteType={noteType} className={className}>
|
||||
{children}
|
||||
</Badge>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { AlertTriangle, Info } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { useTranslation } from '~/lib/i18n/useTranslation';
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { AlertTriangle, Info } from "lucide-react";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
import { useTranslation } from "~/lib/i18n/useTranslation";
|
||||
|
||||
interface ConfirmationModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -12,7 +12,7 @@ interface ConfirmationModalProps {
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
variant: 'simple' | 'danger';
|
||||
variant: "simple" | "danger";
|
||||
confirmText?: string; // What the user must type for danger variant
|
||||
confirmButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
@@ -27,19 +27,19 @@ export function ConfirmationModal({
|
||||
variant,
|
||||
confirmText,
|
||||
confirmButtonText,
|
||||
cancelButtonText
|
||||
cancelButtonText,
|
||||
}: ConfirmationModalProps) {
|
||||
const { t } = useTranslation('confirmationModal');
|
||||
const { t: tc } = useTranslation('common.actions');
|
||||
const [typedText, setTypedText] = useState('');
|
||||
const isDanger = variant === 'danger';
|
||||
const { t } = useTranslation("confirmationModal");
|
||||
const { t: tc } = useTranslation("common.actions");
|
||||
const [typedText, setTypedText] = useState("");
|
||||
const isDanger = variant === "danger";
|
||||
const allowEscape = useMemo(() => !isDanger, [isDanger]);
|
||||
|
||||
// Use provided button texts or fallback to translations
|
||||
const finalConfirmText = confirmButtonText ?? tc('confirm');
|
||||
const finalCancelText = cancelButtonText ?? tc('cancel');
|
||||
const finalConfirmText = confirmButtonText ?? tc("confirm");
|
||||
const finalCancelText = cancelButtonText ?? tc("cancel");
|
||||
|
||||
useRegisterModal(isOpen, { id: 'confirmation-modal', allowEscape, onClose });
|
||||
useRegisterModal(isOpen, { id: "confirmation-modal", allowEscape, onClose });
|
||||
|
||||
if (!isOpen) return null;
|
||||
const isConfirmEnabled = isDanger ? typedText === confirmText : true;
|
||||
@@ -47,57 +47,67 @@ export function ConfirmationModal({
|
||||
const handleConfirm = () => {
|
||||
if (isConfirmEnabled) {
|
||||
onConfirm();
|
||||
setTypedText(''); // Reset for next time
|
||||
setTypedText(""); // Reset for next time
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setTypedText(''); // Reset when closing
|
||||
setTypedText(""); // Reset when closing
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||
<div className="border-border flex items-center justify-center border-b p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{isDanger ? (
|
||||
<AlertTriangle className="h-8 w-8 text-error" />
|
||||
<AlertTriangle className="text-error h-8 w-8" />
|
||||
) : (
|
||||
<Info className="h-8 w-8 text-info" />
|
||||
<Info className="text-info h-8 w-8" />
|
||||
)}
|
||||
<h2 className="text-2xl font-bold text-card-foreground">{title}</h2>
|
||||
<h2 className="text-card-foreground text-2xl font-bold">{title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
{message}
|
||||
</p>
|
||||
<p className="text-muted-foreground mb-6 text-sm">{message}</p>
|
||||
|
||||
{/* Type-to-confirm input for danger variant */}
|
||||
{isDanger && confirmText && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('typeToConfirm', { values: { text: confirmText } }).split(confirmText)[0]}
|
||||
<code className="bg-muted px-2 py-1 rounded text-sm">{confirmText}</code>
|
||||
{t('typeToConfirm', { values: { text: confirmText } }).split(confirmText)[1]}
|
||||
<label className="text-foreground mb-2 block text-sm font-medium">
|
||||
{
|
||||
t("typeToConfirm", { values: { text: confirmText } }).split(
|
||||
confirmText,
|
||||
)[0]
|
||||
}
|
||||
<code className="bg-muted rounded px-2 py-1 text-sm">
|
||||
{confirmText}
|
||||
</code>
|
||||
{
|
||||
t("typeToConfirm", { values: { text: confirmText } }).split(
|
||||
confirmText,
|
||||
)[1]
|
||||
}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typedText}
|
||||
onChange={(e) => setTypedText(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
placeholder={t('placeholder', { values: { text: confirmText } })}
|
||||
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus:ring-ring focus:border-ring w-full rounded-md border px-3 py-2 focus:ring-2 focus:outline-none"
|
||||
placeholder={t("placeholder", {
|
||||
values: { text: confirmText },
|
||||
})}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row justify-end gap-3">
|
||||
<div className="flex flex-col justify-end gap-3 sm:flex-row">
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
variant="outline"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { useTranslation } from '~/lib/i18n/useTranslation';
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { AlertCircle, CheckCircle } from "lucide-react";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
import { useTranslation } from "~/lib/i18n/useTranslation";
|
||||
|
||||
interface ErrorModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -12,7 +12,7 @@ interface ErrorModalProps {
|
||||
title: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
type?: 'error' | 'success';
|
||||
type?: "error" | "success";
|
||||
}
|
||||
|
||||
export function ErrorModal({
|
||||
@@ -21,11 +21,11 @@ export function ErrorModal({
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type = 'error'
|
||||
type = "error",
|
||||
}: ErrorModalProps) {
|
||||
const { t } = useTranslation('errorModal');
|
||||
const { t: tc } = useTranslation('common.actions');
|
||||
useRegisterModal(isOpen, { id: 'error-modal', allowEscape: true, onClose });
|
||||
const { t } = useTranslation("errorModal");
|
||||
const { t: tc } = useTranslation("common.actions");
|
||||
useRegisterModal(isOpen, { id: "error-modal", allowEscape: true, onClose });
|
||||
// Auto-close after 10 seconds
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -39,41 +39,47 @@ export function ErrorModal({
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-lg w-full border border-border">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-card border-border w-full max-w-lg rounded-lg border shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||
<div className="border-border flex items-center justify-center border-b p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{type === 'success' ? (
|
||||
<CheckCircle className="h-8 w-8 text-success" />
|
||||
{type === "success" ? (
|
||||
<CheckCircle className="text-success h-8 w-8" />
|
||||
) : (
|
||||
<AlertCircle className="h-8 w-8 text-error" />
|
||||
<AlertCircle className="text-error h-8 w-8" />
|
||||
)}
|
||||
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
|
||||
<h2 className="text-foreground text-xl font-semibold">{title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-foreground mb-4">{message}</p>
|
||||
<p className="text-foreground mb-4 text-sm">{message}</p>
|
||||
{details && (
|
||||
<div className={`rounded-lg p-3 ${
|
||||
type === 'success'
|
||||
? 'bg-success/10 border border-success/20'
|
||||
: 'bg-error/10 border border-error/20'
|
||||
}`}>
|
||||
<p className={`text-xs font-medium mb-1 ${
|
||||
type === 'success'
|
||||
? 'text-success-foreground'
|
||||
: 'text-error-foreground'
|
||||
}`}>
|
||||
{type === 'success' ? t('detailsLabel') : t('errorDetailsLabel')}
|
||||
<div
|
||||
className={`rounded-lg p-3 ${
|
||||
type === "success"
|
||||
? "bg-success/10 border-success/20 border"
|
||||
: "bg-error/10 border-error/20 border"
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`mb-1 text-xs font-medium ${
|
||||
type === "success"
|
||||
? "text-success-foreground"
|
||||
: "text-error-foreground"
|
||||
}`}
|
||||
>
|
||||
{type === "success"
|
||||
? t("detailsLabel")
|
||||
: t("errorDetailsLabel")}
|
||||
</p>
|
||||
<pre className={`text-xs whitespace-pre-wrap break-words ${
|
||||
type === 'success'
|
||||
? 'text-success/80'
|
||||
: 'text-error/80'
|
||||
}`}>
|
||||
<pre
|
||||
className={`text-xs break-words whitespace-pre-wrap ${
|
||||
type === "success" ? "text-success/80" : "text-error/80"
|
||||
}`}
|
||||
>
|
||||
{details}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -81,9 +87,9 @@ export function ErrorModal({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 p-6 border-t border-border">
|
||||
<div className="border-border flex justify-end gap-3 border-t p-6">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{tc('close')}
|
||||
{tc("close")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Server } from '../../types/server';
|
||||
import { Button } from './ui/button';
|
||||
import { ColorCodedDropdown } from './ColorCodedDropdown';
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { useTranslation } from '@/lib/i18n/useTranslation';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Server } from "../../types/server";
|
||||
import { Button } from "./ui/button";
|
||||
import { ColorCodedDropdown } from "./ColorCodedDropdown";
|
||||
import { SettingsModal } from "./SettingsModal";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
import { useTranslation } from "@/lib/i18n/useTranslation";
|
||||
|
||||
interface ExecutionModeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onExecute: (mode: 'local' | 'ssh', server?: Server) => void;
|
||||
onExecute: (mode: "local" | "ssh", server?: Server) => void;
|
||||
scriptName: string;
|
||||
}
|
||||
|
||||
export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: ExecutionModeModalProps) {
|
||||
const { t } = useTranslation('executionModeModal');
|
||||
useRegisterModal(isOpen, { id: 'execution-mode-modal', allowEscape: true, onClose });
|
||||
export function ExecutionModeModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onExecute,
|
||||
scriptName,
|
||||
}: ExecutionModeModalProps) {
|
||||
const { t } = useTranslation("executionModeModal");
|
||||
useRegisterModal(isOpen, {
|
||||
id: "execution-mode-modal",
|
||||
allowEscape: true,
|
||||
onClose,
|
||||
});
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -36,18 +44,18 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch('/api/servers');
|
||||
const response = await fetch("/api/servers");
|
||||
if (!response.ok) {
|
||||
throw new Error(t('errors.fetchFailed'));
|
||||
throw new Error(t("errors.fetchFailed"));
|
||||
}
|
||||
const data = await response.json();
|
||||
// Sort servers by name alphabetically
|
||||
const sortedServers = (data as Server[]).sort((a, b) =>
|
||||
(a.name ?? '').localeCompare(b.name ?? '')
|
||||
(a.name ?? "").localeCompare(b.name ?? ""),
|
||||
);
|
||||
setServers(sortedServers);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('errors.fetchFailed'));
|
||||
setError(err instanceof Error ? err.message : t("errors.fetchFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -57,7 +65,7 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
if (isOpen) {
|
||||
void fetchServers();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
// Auto-select server when exactly one server is available
|
||||
@@ -69,37 +77,45 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
|
||||
const handleExecute = () => {
|
||||
if (!selectedServer) {
|
||||
setError(t('errors.noServerSelected'));
|
||||
setError(t("errors.noServerSelected"));
|
||||
return;
|
||||
}
|
||||
|
||||
onExecute('ssh', selectedServer);
|
||||
onExecute("ssh", selectedServer);
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
||||
const handleServerSelect = (server: Server | null) => {
|
||||
setSelectedServer(server);
|
||||
};
|
||||
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<h2 className="text-xl font-bold text-foreground">{t('title')}</h2>
|
||||
<div className="border-border flex items-center justify-between border-b p-6">
|
||||
<h2 className="text-foreground text-xl font-bold">{t("title")}</h2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -107,60 +123,72 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<div className="bg-destructive/10 border-destructive/20 mb-4 rounded-md border p-3">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-destructive" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
<svg
|
||||
className="text-destructive h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t('loadingServers')}</p>
|
||||
<div className="py-8 text-center">
|
||||
<div className="border-primary inline-block h-8 w-8 animate-spin rounded-full border-b-2"></div>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{t("loadingServers")}
|
||||
</p>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p className="text-sm">{t('noServersConfigured')}</p>
|
||||
<p className="text-xs mt-1">{t('addServersHint')}</p>
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
<p className="text-sm">{t("noServersConfigured")}</p>
|
||||
<p className="mt-1 text-xs">{t("addServersHint")}</p>
|
||||
<Button
|
||||
onClick={() => setSettingsModalOpen(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
>
|
||||
{t('openServerSettings')}
|
||||
{t("openServerSettings")}
|
||||
</Button>
|
||||
</div>
|
||||
) : servers.length === 1 ? (
|
||||
/* Single Server Confirmation View */
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{t('installConfirmation.title')}
|
||||
<h3 className="text-foreground mb-2 text-lg font-medium">
|
||||
{t("installConfirmation.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('installConfirmation.description', { values: { scriptName } })}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("installConfirmation.description", {
|
||||
values: { scriptName },
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4 border border-border">
|
||||
<div className="bg-muted/50 border-border rounded-lg border p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-3 h-3 bg-success rounded-full"></div>
|
||||
<div className="bg-success h-3 w-3 rounded-full"></div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{selectedServer?.name ?? t('unnamedServer')}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{selectedServer?.name ?? t("unnamedServer")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{selectedServer?.ip}
|
||||
</p>
|
||||
</div>
|
||||
@@ -169,19 +197,15 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
{t('actions.cancel')}
|
||||
<Button onClick={onClose} variant="outline" size="default">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleExecute}
|
||||
variant="default"
|
||||
size="default"
|
||||
>
|
||||
{t('actions.install')}
|
||||
{t("actions.install")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,41 +213,44 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
/* Multiple Servers Selection View */
|
||||
<div className="space-y-6">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{t('multipleServers.title', { values: { scriptName } })}
|
||||
<h3 className="text-foreground mb-2 text-lg font-medium">
|
||||
{t("multipleServers.title", { values: { scriptName } })}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Server Selection */}
|
||||
<div className="mb-6">
|
||||
<label htmlFor="server" className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('multipleServers.selectServerLabel')}
|
||||
<label
|
||||
htmlFor="server"
|
||||
className="text-foreground mb-2 block text-sm font-medium"
|
||||
>
|
||||
{t("multipleServers.selectServerLabel")}
|
||||
</label>
|
||||
<ColorCodedDropdown
|
||||
servers={servers}
|
||||
selectedServer={selectedServer}
|
||||
onServerSelect={handleServerSelect}
|
||||
placeholder={t('multipleServers.placeholder')}
|
||||
placeholder={t("multipleServers.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
{t('actions.cancel')}
|
||||
<Button onClick={onClose} variant="outline" size="default">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleExecute}
|
||||
disabled={!selectedServer}
|
||||
variant="default"
|
||||
size="default"
|
||||
className={!selectedServer ? 'bg-muted-foreground cursor-not-allowed' : ''}
|
||||
className={
|
||||
!selectedServer
|
||||
? "bg-muted-foreground cursor-not-allowed"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{t('actions.runOnServer')}
|
||||
{t("actions.runOnServer")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { HelpModal } from './HelpModal';
|
||||
import { Button } from './ui/button';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
import { useTranslation } from '@/lib/i18n/useTranslation';
|
||||
import { useState } from "react";
|
||||
import { HelpModal } from "./HelpModal";
|
||||
import { Button } from "./ui/button";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { useTranslation } from "@/lib/i18n/useTranslation";
|
||||
|
||||
interface HelpButtonProps {
|
||||
initialSection?: string;
|
||||
}
|
||||
|
||||
export function HelpButton({ initialSection }: HelpButtonProps) {
|
||||
const { t } = useTranslation('helpButton');
|
||||
const { t } = useTranslation("helpButton");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="text-sm text-muted-foreground font-medium">
|
||||
{t('needHelp')}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="text-muted-foreground text-sm font-medium">
|
||||
{t("needHelp")}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="inline-flex items-center"
|
||||
title={t('openHelp')}
|
||||
title={t("openHelp")}
|
||||
>
|
||||
<HelpCircle className="w-5 h-5 mr-2" />
|
||||
{t('help')}
|
||||
<HelpCircle className="mr-2 h-5 w-5" />
|
||||
{t("help")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { useTranslation } from '@/lib/i18n/useTranslation';
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
import { useTranslation } from "@/lib/i18n/useTranslation";
|
||||
|
||||
interface LoadingModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -10,27 +10,29 @@ interface LoadingModalProps {
|
||||
}
|
||||
|
||||
export function LoadingModal({ isOpen, action }: LoadingModalProps) {
|
||||
const { t } = useTranslation('loadingModal');
|
||||
useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: false, onClose: () => null });
|
||||
const { t } = useTranslation("loadingModal");
|
||||
useRegisterModal(isOpen, {
|
||||
id: "loading-modal",
|
||||
allowEscape: false,
|
||||
onClose: () => null,
|
||||
});
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border p-8">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-card border-border w-full max-w-md rounded-lg border p-8 shadow-xl">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="relative">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
|
||||
<Loader2 className="text-primary h-12 w-12 animate-spin" />
|
||||
<div className="border-primary/20 absolute inset-0 animate-pulse rounded-full border-2"></div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-card-foreground mb-2">
|
||||
{t('processing')}
|
||||
<h3 className="text-card-foreground mb-2 text-lg font-semibold">
|
||||
{t("processing")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{action}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{t('pleaseWait')}
|
||||
<p className="text-muted-foreground text-sm">{action}</p>
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{t("pleaseWait")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -38,4 +40,3 @@ export function LoadingModal({ isOpen, action }: LoadingModalProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X, Copy, Check, Server, Globe } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { useTranslation } from '@/lib/i18n/useTranslation';
|
||||
import { useState } from "react";
|
||||
import { X, Copy, Check, Server, Globe } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
import { useTranslation } from "@/lib/i18n/useTranslation";
|
||||
|
||||
interface PublicKeyModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -14,9 +14,19 @@ interface PublicKeyModalProps {
|
||||
serverIp: string;
|
||||
}
|
||||
|
||||
export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverIp }: PublicKeyModalProps) {
|
||||
const { t } = useTranslation('publicKeyModal');
|
||||
useRegisterModal(isOpen, { id: 'public-key-modal', allowEscape: true, onClose });
|
||||
export function PublicKeyModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
publicKey,
|
||||
serverName,
|
||||
serverIp,
|
||||
}: PublicKeyModalProps) {
|
||||
const { t } = useTranslation("publicKeyModal");
|
||||
useRegisterModal(isOpen, {
|
||||
id: "public-key-modal",
|
||||
allowEscape: true,
|
||||
onClose,
|
||||
});
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [commandCopied, setCommandCopied] = useState(false);
|
||||
|
||||
@@ -31,31 +41,31 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
// Fallback for older browsers or non-HTTPS
|
||||
const textArea = document.createElement('textarea');
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = publicKey;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.left = "-999999px";
|
||||
textArea.style.top = "-999999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
document.execCommand("copy");
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (fallbackError) {
|
||||
console.error('Fallback copy failed:', fallbackError);
|
||||
console.error("Fallback copy failed:", fallbackError);
|
||||
// If all else fails, show the key in an alert
|
||||
alert(t('copyFallback') + publicKey);
|
||||
alert(t("copyFallback") + publicKey);
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error);
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
// Fallback: show the key in an alert
|
||||
alert(t('copyFallback') + publicKey);
|
||||
alert(t("copyFallback") + publicKey);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -69,44 +79,46 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
|
||||
setTimeout(() => setCommandCopied(false), 2000);
|
||||
} else {
|
||||
// Fallback for older browsers or non-HTTPS
|
||||
const textArea = document.createElement('textarea');
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = command;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.left = "-999999px";
|
||||
textArea.style.top = "-999999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
document.execCommand("copy");
|
||||
setCommandCopied(true);
|
||||
setTimeout(() => setCommandCopied(false), 2000);
|
||||
} catch (fallbackError) {
|
||||
console.error('Fallback copy failed:', fallbackError);
|
||||
alert(t('copyCommandFallback') + command);
|
||||
console.error("Fallback copy failed:", fallbackError);
|
||||
alert(t("copyCommandFallback") + command);
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy command to clipboard:', error);
|
||||
alert(t('copyCommandFallback') + command);
|
||||
console.error("Failed to copy command to clipboard:", error);
|
||||
alert(t("copyCommandFallback") + command);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-card border-border w-full max-w-2xl rounded-lg border shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<div className="border-border flex items-center justify-between border-b p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-info/10 rounded-lg">
|
||||
<Server className="h-6 w-6 text-info" />
|
||||
<div className="bg-info/10 rounded-lg p-2">
|
||||
<Server className="text-info h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-card-foreground">{t('title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('subtitle')}</p>
|
||||
<h2 className="text-card-foreground text-xl font-semibold">
|
||||
{t("title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -120,14 +132,14 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Server Info */}
|
||||
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="bg-muted/50 flex items-center gap-4 rounded-lg p-4">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="font-medium">{serverName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span>{serverIp}</span>
|
||||
</div>
|
||||
@@ -135,19 +147,39 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium text-foreground">{t('instructions.title')}</h3>
|
||||
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
|
||||
<li>{t('instructions.step1')}</li>
|
||||
<li>{t('instructions.step2')} <code className="bg-muted px-1 rounded">ssh root@{serverIp}</code></li>
|
||||
<li>{t('instructions.step3')} <code className="bg-muted px-1 rounded">echo "<paste-key>" >> ~/.ssh/authorized_keys</code></li>
|
||||
<li>{t('instructions.step4')} <code className="bg-muted px-1 rounded">chmod 600 ~/.ssh/authorized_keys</code></li>
|
||||
<h3 className="text-foreground font-medium">
|
||||
{t("instructions.title")}
|
||||
</h3>
|
||||
<ol className="text-muted-foreground list-inside list-decimal space-y-1 text-sm">
|
||||
<li>{t("instructions.step1")}</li>
|
||||
<li>
|
||||
{t("instructions.step2")}{" "}
|
||||
<code className="bg-muted rounded px-1">
|
||||
ssh root@{serverIp}
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
{t("instructions.step3")}{" "}
|
||||
<code className="bg-muted rounded px-1">
|
||||
echo "<paste-key>" >>
|
||||
~/.ssh/authorized_keys
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
{t("instructions.step4")}{" "}
|
||||
<code className="bg-muted rounded px-1">
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
</code>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Public Key */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">{t('publicKeyLabel')}</label>
|
||||
<label className="text-foreground text-sm font-medium">
|
||||
{t("publicKeyLabel")}
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -157,12 +189,12 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
{t('actions.copied')}
|
||||
{t("actions.copied")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
{t('actions.copy')}
|
||||
{t("actions.copy")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -170,15 +202,17 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
|
||||
<textarea
|
||||
value={publicKey}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground font-mono text-xs min-h-[60px] resize-none border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
placeholder={t('placeholder')}
|
||||
className="bg-card text-foreground border-border focus:ring-ring focus:border-ring min-h-[60px] w-full resize-none rounded-md border px-3 py-2 font-mono text-xs shadow-sm focus:ring-2 focus:outline-none"
|
||||
placeholder={t("placeholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Command */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">{t('quickCommandLabel')}</label>
|
||||
<label className="text-foreground text-sm font-medium">
|
||||
{t("quickCommandLabel")}
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -188,30 +222,30 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
|
||||
{commandCopied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
{t('actions.copied')}
|
||||
{t("actions.copied")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
{t('actions.copyCommand')}
|
||||
{t("actions.copyCommand")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-3 bg-muted/50 rounded-md border border-border">
|
||||
<code className="text-sm font-mono text-foreground break-all">
|
||||
<div className="bg-muted/50 border-border rounded-md border p-3">
|
||||
<code className="text-foreground font-mono text-sm break-all">
|
||||
echo "{publicKey}" >> ~/.ssh/authorized_keys
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('quickCommandHint')}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("quickCommandHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||||
<div className="border-border flex justify-end gap-3 border-t pt-4">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('actions.close')}
|
||||
{t("actions.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||
import { useTranslation } from '@/lib/i18n/useTranslation';
|
||||
import { useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Button } from "./ui/button";
|
||||
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
||||
import { useTranslation } from "@/lib/i18n/useTranslation";
|
||||
|
||||
export function ResyncButton() {
|
||||
const { t } = useTranslation('resyncButton');
|
||||
const { t } = useTranslation("resyncButton");
|
||||
const [isResyncing, setIsResyncing] = useState(false);
|
||||
const [lastSync, setLastSync] = useState<Date | null>(null);
|
||||
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||
@@ -17,20 +17,22 @@ export function ResyncButton() {
|
||||
setIsResyncing(false);
|
||||
setLastSync(new Date());
|
||||
if (data.success) {
|
||||
setSyncMessage(data.message ?? t('messages.success'));
|
||||
setSyncMessage(data.message ?? t("messages.success"));
|
||||
// Reload the page after successful sync
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000); // Wait 2 seconds to show the success message
|
||||
} else {
|
||||
setSyncMessage(data.error ?? t('messages.failed'));
|
||||
setSyncMessage(data.error ?? t("messages.failed"));
|
||||
// Clear message after 3 seconds for errors
|
||||
setTimeout(() => setSyncMessage(null), 3000);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsResyncing(false);
|
||||
setSyncMessage(t('messages.error', { values: { message: error.message } }));
|
||||
setSyncMessage(
|
||||
t("messages.error", { values: { message: error.message } }),
|
||||
);
|
||||
setTimeout(() => setSyncMessage(null), 3000);
|
||||
},
|
||||
});
|
||||
@@ -42,11 +44,11 @@ export function ResyncButton() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="text-sm text-muted-foreground font-medium">
|
||||
{t('syncDescription')}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="text-muted-foreground text-sm font-medium">
|
||||
{t("syncDescription")}
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleResync}
|
||||
@@ -57,34 +59,51 @@ export function ResyncButton() {
|
||||
>
|
||||
{isResyncing ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
<span>{t('syncing')}</span>
|
||||
<div className="mr-2 h-5 w-5 animate-spin rounded-full border-b-2 border-white"></div>
|
||||
<span>{t("syncing")}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
<svg
|
||||
className="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>{t('syncJsonFiles')}</span>
|
||||
<span>{t("syncJsonFiles")}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<ContextualHelpIcon section="sync-button" tooltip={t('helpTooltip')} />
|
||||
<ContextualHelpIcon
|
||||
section="sync-button"
|
||||
tooltip={t("helpTooltip")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{lastSync && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('lastSync', { values: { time: lastSync.toLocaleTimeString() } })}
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t("lastSync", { values: { time: lastSync.toLocaleTimeString() } })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{syncMessage && (
|
||||
<div className={`text-sm px-3 py-1 rounded-lg ${
|
||||
syncMessage.includes('Error') || syncMessage.includes('Failed') || syncMessage.includes('Fehler')
|
||||
? 'bg-error/10 text-error'
|
||||
: 'bg-success/10 text-success'
|
||||
}`}>
|
||||
<div
|
||||
className={`rounded-lg px-3 py-1 text-sm ${
|
||||
syncMessage.includes("Error") ||
|
||||
syncMessage.includes("Failed") ||
|
||||
syncMessage.includes("Fehler")
|
||||
? "bg-error/10 text-error"
|
||||
: "bg-success/10 text-success"
|
||||
}`}
|
||||
>
|
||||
{syncMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import type { ScriptCard } from '~/types/script';
|
||||
import { TypeBadge, UpdateableBadge } from './Badge';
|
||||
import { useTranslation } from '@/lib/i18n/useTranslation';
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import type { ScriptCard } from "~/types/script";
|
||||
import { TypeBadge, UpdateableBadge } from "./Badge";
|
||||
import { useTranslation } from "@/lib/i18n/useTranslation";
|
||||
|
||||
interface ScriptCardProps {
|
||||
script: ScriptCard;
|
||||
@@ -13,8 +13,13 @@ interface ScriptCardProps {
|
||||
onToggleSelect?: (slug: string) => void;
|
||||
}
|
||||
|
||||
export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardProps) {
|
||||
const { t } = useTranslation('scriptCard');
|
||||
export function ScriptCard({
|
||||
script,
|
||||
onClick,
|
||||
isSelected = false,
|
||||
onToggleSelect,
|
||||
}: ScriptCardProps) {
|
||||
const { t } = useTranslation("scriptCard");
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const handleImageError = () => {
|
||||
@@ -30,32 +35,36 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col relative"
|
||||
className="bg-card border-border hover:border-primary relative flex h-full cursor-pointer flex-col rounded-lg border shadow-md transition-shadow duration-200 hover:shadow-lg"
|
||||
onClick={() => onClick(script)}
|
||||
>
|
||||
{/* Checkbox in top-left corner */}
|
||||
{onToggleSelect && (
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<div
|
||||
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
|
||||
className={`flex h-4 w-4 cursor-pointer items-center justify-center rounded border-2 transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "bg-card border-border hover:border-primary/60 hover:bg-accent"
|
||||
}`}
|
||||
onClick={handleCheckboxClick}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
<div className="flex flex-1 flex-col p-6">
|
||||
{/* Header with logo and name */}
|
||||
<div className="flex items-start space-x-4 mb-4">
|
||||
<div className="mb-4 flex items-start space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
{script.logo && !imageError ? (
|
||||
<Image
|
||||
@@ -63,37 +72,41 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
||||
alt={`${script.name} logo`}
|
||||
width={48}
|
||||
height={48}
|
||||
className="w-12 h-12 rounded-lg object-contain"
|
||||
className="h-12 w-12 rounded-lg object-contain"
|
||||
onError={handleImageError}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center">
|
||||
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<span className="text-muted-foreground text-lg font-semibold">
|
||||
{script.name?.charAt(0)?.toUpperCase() || '?'}
|
||||
{script.name?.charAt(0)?.toUpperCase() || "?"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-foreground truncate">
|
||||
{script.name || t('unnamedScript')}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-foreground truncate text-lg font-semibold">
|
||||
{script.name || t("unnamedScript")}
|
||||
</h3>
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* Type and Updateable status on first row */}
|
||||
<div className="flex items-center space-x-2 flex-wrap gap-1">
|
||||
<TypeBadge type={script.type ?? 'unknown'} />
|
||||
<div className="flex flex-wrap items-center gap-1 space-x-2">
|
||||
<TypeBadge type={script.type ?? "unknown"} />
|
||||
{script.updateable && <UpdateableBadge />}
|
||||
</div>
|
||||
|
||||
{/* Download Status */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
script.isDownloaded ? 'bg-success' : 'bg-error'
|
||||
}`}></div>
|
||||
<span className={`text-xs font-medium ${
|
||||
script.isDownloaded ? 'text-success' : 'text-error'
|
||||
}`}>
|
||||
{script.isDownloaded ? t('downloaded') : t('notDownloaded')}
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
script.isDownloaded ? "bg-success" : "bg-error"
|
||||
}`}
|
||||
></div>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
script.isDownloaded ? "text-success" : "text-error"
|
||||
}`}
|
||||
>
|
||||
{script.isDownloaded ? t("downloaded") : t("notDownloaded")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,8 +114,8 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-muted-foreground text-sm line-clamp-3 mb-4 flex-1">
|
||||
{script.description || t('noDescription')}
|
||||
<p className="text-muted-foreground mb-4 line-clamp-3 flex-1 text-sm">
|
||||
{script.description || t("noDescription")}
|
||||
</p>
|
||||
|
||||
{/* Footer with website link */}
|
||||
@@ -112,12 +125,22 @@ export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect
|
||||
href={script.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info hover:text-info/80 text-sm font-medium flex items-center space-x-1"
|
||||
className="text-info hover:text-info/80 flex items-center space-x-1 text-sm font-medium"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>{t('website')}</span>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
<span>{t("website")}</span>
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
import { Button } from './ui/button';
|
||||
import { useTranslation } from '@/lib/i18n/useTranslation';
|
||||
import { useState } from "react";
|
||||
import { SettingsModal } from "./SettingsModal";
|
||||
import { Button } from "./ui/button";
|
||||
import { useTranslation } from "@/lib/i18n/useTranslation";
|
||||
|
||||
export function ServerSettingsButton() {
|
||||
const { t } = useTranslation('serverSettingsButton');
|
||||
const { t } = useTranslation("serverSettingsButton");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="text-sm text-muted-foreground font-medium">
|
||||
{t('description')}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="text-muted-foreground text-sm font-medium">
|
||||
{t("description")}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="inline-flex items-center"
|
||||
title={t('buttonTitle')}
|
||||
title={t("buttonTitle")}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
className="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -42,7 +42,7 @@ export function ServerSettingsButton() {
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
{t('buttonLabel')}
|
||||
{t("buttonLabel")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { GeneralSettingsModal } from './GeneralSettingsModal';
|
||||
import { Button } from './ui/button';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { useTranslation } from '@/lib/i18n/useTranslation';
|
||||
import { useState } from "react";
|
||||
import { GeneralSettingsModal } from "./GeneralSettingsModal";
|
||||
import { Button } from "./ui/button";
|
||||
import { Settings } from "lucide-react";
|
||||
import { useTranslation } from "@/lib/i18n/useTranslation";
|
||||
|
||||
export function SettingsButton() {
|
||||
const { t } = useTranslation('settingsButton');
|
||||
const { t } = useTranslation("settingsButton");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="text-sm text-muted-foreground font-medium">
|
||||
{t('description')}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="text-muted-foreground text-sm font-medium">
|
||||
{t("description")}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="inline-flex items-center"
|
||||
title={t('buttonTitle')}
|
||||
title={t("buttonTitle")}
|
||||
>
|
||||
<Settings className="w-5 h-5 mr-2" />
|
||||
{t('buttonLabel')}
|
||||
<Settings className="mr-2 h-5 w-5" />
|
||||
{t("buttonLabel")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Toggle } from './ui/toggle';
|
||||
import { Lock, User, Shield, AlertCircle } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { useTranslation } from '@/lib/i18n/useTranslation';
|
||||
import { useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Toggle } from "./ui/toggle";
|
||||
import { Lock, User, Shield, AlertCircle } from "lucide-react";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
import { useTranslation } from "@/lib/i18n/useTranslation";
|
||||
|
||||
interface SetupModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -14,11 +14,15 @@ interface SetupModalProps {
|
||||
}
|
||||
|
||||
export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
|
||||
const { t } = useTranslation('setupModal');
|
||||
useRegisterModal(isOpen, { id: 'setup-modal', allowEscape: true, onClose: () => null });
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const { t } = useTranslation("setupModal");
|
||||
useRegisterModal(isOpen, {
|
||||
id: "setup-modal",
|
||||
allowEscape: true,
|
||||
onClose: () => null,
|
||||
});
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [enableAuth, setEnableAuth] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -30,31 +34,31 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
|
||||
|
||||
// Only validate passwords if authentication is enabled
|
||||
if (enableAuth && password !== confirmPassword) {
|
||||
setError(t('errors.passwordMismatch'));
|
||||
setError(t("errors.passwordMismatch"));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/setup', {
|
||||
method: 'POST',
|
||||
const response = await fetch("/api/auth/setup", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: enableAuth ? username : undefined,
|
||||
password: enableAuth ? password : undefined,
|
||||
enabled: enableAuth
|
||||
enabled: enableAuth,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// If authentication is enabled, automatically log in the user
|
||||
if (enableAuth) {
|
||||
const loginResponse = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
const loginResponse = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
@@ -64,7 +68,7 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
|
||||
onComplete();
|
||||
} else {
|
||||
// Setup succeeded but login failed, still complete setup
|
||||
console.warn('Setup completed but auto-login failed');
|
||||
console.warn("Setup completed but auto-login failed");
|
||||
onComplete();
|
||||
}
|
||||
} else {
|
||||
@@ -72,12 +76,12 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
|
||||
onComplete();
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json() as { error: string };
|
||||
setError(errorData.error ?? t('errors.setupFailed'));
|
||||
const errorData = (await response.json()) as { error: string };
|
||||
setError(errorData.error ?? t("errors.setupFailed"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Setup error:', error);
|
||||
setError(t('errors.setupFailed'));
|
||||
console.error("Setup error:", error);
|
||||
setError(t("errors.setupFailed"));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
@@ -86,105 +90,117 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-card border-border w-full max-w-md rounded-lg border shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||
<div className="border-border flex items-center justify-center border-b p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="h-8 w-8 text-success" />
|
||||
<h2 className="text-2xl font-bold text-card-foreground">{t('title')}</h2>
|
||||
<Shield className="text-success h-8 w-8" />
|
||||
<h2 className="text-card-foreground text-2xl font-bold">
|
||||
{t("title")}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<p className="text-muted-foreground text-center mb-6">
|
||||
{t('description')}
|
||||
<p className="text-muted-foreground mb-6 text-center">
|
||||
{t("description")}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="setup-username" className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('username.label')}
|
||||
<label
|
||||
htmlFor="setup-username"
|
||||
className="text-foreground mb-2 block text-sm font-medium"
|
||||
>
|
||||
{t("username.label")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="setup-username"
|
||||
type="text"
|
||||
placeholder={t('username.placeholder')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="pl-10"
|
||||
required={enableAuth}
|
||||
minLength={3}
|
||||
/>
|
||||
<User className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
id="setup-username"
|
||||
type="text"
|
||||
placeholder={t("username.placeholder")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="pl-10"
|
||||
required={enableAuth}
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="setup-password" className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('password.label')}
|
||||
<label
|
||||
htmlFor="setup-password"
|
||||
className="text-foreground mb-2 block text-sm font-medium"
|
||||
>
|
||||
{t("password.label")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="setup-password"
|
||||
type="password"
|
||||
placeholder={t('password.placeholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="pl-10"
|
||||
required={enableAuth}
|
||||
minLength={6}
|
||||
/>
|
||||
<Lock className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
id="setup-password"
|
||||
type="password"
|
||||
placeholder={t("password.placeholder")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="pl-10"
|
||||
required={enableAuth}
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirm-password" className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('confirmPassword.label')}
|
||||
<label
|
||||
htmlFor="confirm-password"
|
||||
className="text-foreground mb-2 block text-sm font-medium"
|
||||
>
|
||||
{t("confirmPassword.label")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
placeholder={t('confirmPassword.placeholder')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="pl-10"
|
||||
required={enableAuth}
|
||||
minLength={6}
|
||||
/>
|
||||
<Lock className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
placeholder={t("confirmPassword.placeholder")}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="pl-10"
|
||||
required={enableAuth}
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg bg-muted/30">
|
||||
<div className="border-border bg-muted/30 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground mb-1">{t('enableAuth.title')}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<h4 className="text-foreground mb-1 font-medium">
|
||||
{t("enableAuth.title")}
|
||||
</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{enableAuth
|
||||
? t('enableAuth.descriptionEnabled')
|
||||
: t('enableAuth.descriptionDisabled')
|
||||
}
|
||||
? t("enableAuth.descriptionEnabled")
|
||||
: t("enableAuth.descriptionDisabled")}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={enableAuth}
|
||||
onCheckedChange={setEnableAuth}
|
||||
disabled={isLoading}
|
||||
label={t('enableAuth.label')}
|
||||
label={t("enableAuth.label")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-error/10 text-error-foreground border border-error/20 rounded-md">
|
||||
<div className="bg-error/10 text-error-foreground border-error/20 flex items-center gap-2 rounded-md border p-3">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
@@ -194,11 +210,14 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
|
||||
type="submit"
|
||||
disabled={
|
||||
isLoading ||
|
||||
(enableAuth && (!username.trim() || !password.trim() || !confirmPassword.trim()))
|
||||
(enableAuth &&
|
||||
(!username.trim() ||
|
||||
!password.trim() ||
|
||||
!confirmPassword.trim()))
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? t('actions.settingUp') : t('actions.completeSetup')}
|
||||
{isLoading ? t("actions.settingUp") : t("actions.completeSetup")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { Badge } from "./ui/badge";
|
||||
@@ -16,51 +16,51 @@ interface VersionDisplayProps {
|
||||
// Loading overlay component with log streaming
|
||||
function LoadingOverlay({
|
||||
isNetworkError = false,
|
||||
logs = []
|
||||
logs = [],
|
||||
}: {
|
||||
isNetworkError?: boolean;
|
||||
logs?: string[];
|
||||
}) {
|
||||
const { t } = useTranslation('versionDisplay.loadingOverlay');
|
||||
const { t } = useTranslation("versionDisplay.loadingOverlay");
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
useEffect(() => {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [logs]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-card rounded-lg p-8 shadow-2xl border border-border max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
|
||||
<div className="bg-card border-border mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg border p-8 shadow-2xl">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="relative">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
|
||||
<Loader2 className="text-primary h-12 w-12 animate-spin" />
|
||||
<div className="border-primary/20 absolute inset-0 animate-pulse rounded-full border-2"></div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-card-foreground mb-2">
|
||||
{isNetworkError ? t('serverRestarting') : t('updatingApplication')}
|
||||
<h3 className="text-card-foreground mb-2 text-lg font-semibold">
|
||||
{isNetworkError
|
||||
? t("serverRestarting")
|
||||
: t("updatingApplication")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{isNetworkError
|
||||
? t('serverRestartingMessage')
|
||||
: t('updatingMessage')
|
||||
}
|
||||
? t("serverRestartingMessage")
|
||||
: t("updatingMessage")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{isNetworkError
|
||||
? t('serverRestartingNote')
|
||||
: t('updatingNote')
|
||||
}
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{isNetworkError ? t("serverRestartingNote") : t("updatingNote")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Log output */}
|
||||
{logs.length > 0 && (
|
||||
<div className="w-full mt-4 bg-card border border-border rounded-lg p-4 font-mono text-xs text-chart-2 max-h-60 overflow-y-auto terminal-output">
|
||||
<div className="bg-card border-border text-chart-2 terminal-output mt-4 max-h-60 w-full overflow-y-auto rounded-lg border p-4 font-mono text-xs">
|
||||
{logs.map((log, index) => (
|
||||
<div key={index} className="mb-1 whitespace-pre-wrap break-words">
|
||||
<div
|
||||
key={index}
|
||||
className="mb-1 break-words whitespace-pre-wrap"
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
@@ -69,9 +69,15 @@ function LoadingOverlay({
|
||||
)}
|
||||
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
<div className="bg-primary h-2 w-2 animate-bounce rounded-full"></div>
|
||||
<div
|
||||
className="bg-primary h-2 w-2 animate-bounce rounded-full"
|
||||
style={{ animationDelay: "0.1s" }}
|
||||
></div>
|
||||
<div
|
||||
className="bg-primary h-2 w-2 animate-bounce rounded-full"
|
||||
style={{ animationDelay: "0.2s" }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,12 +85,21 @@ function LoadingOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) {
|
||||
const { t } = useTranslation('versionDisplay');
|
||||
const { t: tOverlay } = useTranslation('versionDisplay.loadingOverlay');
|
||||
const { data: versionStatus, isLoading, error } = api.version.getVersionStatus.useQuery();
|
||||
export function VersionDisplay({
|
||||
onOpenReleaseNotes,
|
||||
}: VersionDisplayProps = {}) {
|
||||
const { t } = useTranslation("versionDisplay");
|
||||
const { t: tOverlay } = useTranslation("versionDisplay.loadingOverlay");
|
||||
const {
|
||||
data: versionStatus,
|
||||
isLoading,
|
||||
error,
|
||||
} = api.version.getVersionStatus.useQuery();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [updateResult, setUpdateResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [updateResult, setUpdateResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [isNetworkError, setIsNetworkError] = useState(false);
|
||||
const [updateLogs, setUpdateLogs] = useState<string[]>([]);
|
||||
const [shouldSubscribe, setShouldSubscribe] = useState(false);
|
||||
@@ -99,7 +114,7 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
if (result.success) {
|
||||
// Start subscribing to update logs
|
||||
setShouldSubscribe(true);
|
||||
setUpdateLogs([tOverlay('updateStarted')]);
|
||||
setUpdateLogs([tOverlay("updateStarted")]);
|
||||
} else {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
@@ -107,29 +122,32 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
onError: (error) => {
|
||||
setUpdateResult({ success: false, message: error.message });
|
||||
setIsUpdating(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Poll for update logs
|
||||
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, {
|
||||
enabled: shouldSubscribe,
|
||||
refetchInterval: 1000, // Poll every second
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: shouldSubscribe,
|
||||
refetchInterval: 1000, // Poll every second
|
||||
refetchIntervalInBackground: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Attempt to reconnect and reload page when server is back
|
||||
const startReconnectAttempts = useCallback(() => {
|
||||
if (reconnectIntervalRef.current) return;
|
||||
|
||||
setUpdateLogs(prev => [...prev, tOverlay('reconnecting')]);
|
||||
setUpdateLogs((prev) => [...prev, tOverlay("reconnecting")]);
|
||||
|
||||
reconnectIntervalRef.current = setInterval(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
// Try to fetch the root path to check if server is back
|
||||
const response = await fetch('/', { method: 'HEAD' });
|
||||
const response = await fetch("/", { method: "HEAD" });
|
||||
if (response.ok || response.status === 200) {
|
||||
setUpdateLogs(prev => [...prev, tOverlay('serverBackOnline')]);
|
||||
setUpdateLogs((prev) => [...prev, tOverlay("serverBackOnline")]);
|
||||
|
||||
// Clear interval and reload
|
||||
if (reconnectIntervalRef.current) {
|
||||
@@ -154,7 +172,7 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
setUpdateLogs(updateLogsData.logs);
|
||||
|
||||
if (updateLogsData.isComplete) {
|
||||
setUpdateLogs(prev => [...prev, tOverlay('updateComplete')]);
|
||||
setUpdateLogs((prev) => [...prev, tOverlay("updateComplete")]);
|
||||
setIsNetworkError(true);
|
||||
// Start reconnection attempts when we know update is complete
|
||||
startReconnectAttempts();
|
||||
@@ -172,12 +190,18 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
|
||||
// Only start reconnection if we've been updating for at least 3 minutes
|
||||
// and no logs for 60 seconds (very conservative fallback)
|
||||
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
|
||||
const hasBeenUpdatingLongEnough =
|
||||
updateStartTime && Date.now() - updateStartTime > 180000; // 3 minutes
|
||||
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
|
||||
|
||||
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) {
|
||||
if (
|
||||
hasBeenUpdatingLongEnough &&
|
||||
noLogsForAWhile &&
|
||||
isUpdating &&
|
||||
!isNetworkError
|
||||
) {
|
||||
setIsNetworkError(true);
|
||||
setUpdateLogs(prev => [...prev, tOverlay('serverRestarting2')]);
|
||||
setUpdateLogs((prev) => [...prev, tOverlay("serverRestarting2")]);
|
||||
|
||||
// Start trying to reconnect
|
||||
startReconnectAttempts();
|
||||
@@ -185,7 +209,14 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
}, 10000); // Check every 10 seconds
|
||||
|
||||
return () => clearInterval(checkInterval);
|
||||
}, [shouldSubscribe, isUpdating, updateStartTime, isNetworkError, tOverlay, startReconnectAttempts]);
|
||||
}, [
|
||||
shouldSubscribe,
|
||||
isUpdating,
|
||||
updateStartTime,
|
||||
isNetworkError,
|
||||
tOverlay,
|
||||
startReconnectAttempts,
|
||||
]);
|
||||
|
||||
// Cleanup reconnect interval on unmount
|
||||
useEffect(() => {
|
||||
@@ -211,7 +242,7 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="animate-pulse">
|
||||
{t('loading')}
|
||||
{t("loading")}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
@@ -221,66 +252,82 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="destructive">
|
||||
v{versionStatus?.currentVersion ?? t('unknownVersion')}
|
||||
v{versionStatus?.currentVersion ?? t("unknownVersion")}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('unableToCheck')}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("unableToCheck")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { currentVersion, isUpToDate, updateAvailable, releaseInfo } = versionStatus;
|
||||
const { currentVersion, isUpToDate, updateAvailable, releaseInfo } =
|
||||
versionStatus;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Loading overlay */}
|
||||
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
|
||||
{isUpdating && (
|
||||
<LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
|
||||
<div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-2">
|
||||
<Badge
|
||||
variant={isUpToDate ? "default" : "secondary"}
|
||||
className={`text-xs ${onOpenReleaseNotes ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
|
||||
className={`text-xs ${onOpenReleaseNotes ? "cursor-pointer transition-opacity hover:opacity-80" : ""}`}
|
||||
onClick={onOpenReleaseNotes}
|
||||
>
|
||||
v{currentVersion}
|
||||
</Badge>
|
||||
|
||||
{updateAvailable && releaseInfo && (
|
||||
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-3">
|
||||
<div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
disabled={isUpdating}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="text-xs h-6 px-2"
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
|
||||
<span className="hidden sm:inline">{t('update.updating')}</span>
|
||||
<span className="sm:hidden">{t('update.updatingShort')}</span>
|
||||
<RefreshCw className="mr-1 h-3 w-3 animate-spin" />
|
||||
<span className="hidden sm:inline">
|
||||
{t("update.updating")}
|
||||
</span>
|
||||
<span className="sm:hidden">
|
||||
{t("update.updatingShort")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
<span className="hidden sm:inline">{t('update.updateNow')}</span>
|
||||
<span className="sm:hidden">{t('update.updateNowShort')}</span>
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
<span className="hidden sm:inline">
|
||||
{t("update.updateNow")}
|
||||
</span>
|
||||
<span className="sm:hidden">
|
||||
{t("update.updateNowShort")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<ContextualHelpIcon section="update-system" tooltip={t('helpTooltip')} />
|
||||
<ContextualHelpIcon
|
||||
section="update-system"
|
||||
tooltip={t("helpTooltip")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">{t('releaseNotes')}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("releaseNotes")}
|
||||
</span>
|
||||
<a
|
||||
href={releaseInfo.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs transition-colors"
|
||||
title="View latest release"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
@@ -288,11 +335,13 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
</div>
|
||||
|
||||
{updateResult && (
|
||||
<div className={`text-xs px-2 py-1 rounded text-center ${
|
||||
updateResult.success
|
||||
? 'bg-chart-2/20 text-chart-2 border border-chart-2/30'
|
||||
: 'bg-destructive/20 text-destructive border border-destructive/30'
|
||||
}`}>
|
||||
<div
|
||||
className={`rounded px-2 py-1 text-center text-xs ${
|
||||
updateResult.success
|
||||
? "bg-chart-2/20 text-chart-2 border-chart-2/30 border"
|
||||
: "bg-destructive/20 text-destructive border-destructive/30 border"
|
||||
}`}
|
||||
>
|
||||
{updateResult.message}
|
||||
</div>
|
||||
)}
|
||||
@@ -300,9 +349,7 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
)}
|
||||
|
||||
{isUpToDate && (
|
||||
<span className="text-xs text-chart-2">
|
||||
{t('upToDate')}
|
||||
</span>
|
||||
<span className="text-chart-2 text-xs">{t("upToDate")}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Grid3X3, List } from 'lucide-react';
|
||||
import { useTranslation } from '@/lib/i18n/useTranslation';
|
||||
import React from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Grid3X3, List } from "lucide-react";
|
||||
import { useTranslation } from "@/lib/i18n/useTranslation";
|
||||
|
||||
interface ViewToggleProps {
|
||||
viewMode: 'card' | 'list';
|
||||
onViewModeChange: (mode: 'card' | 'list') => void;
|
||||
viewMode: "card" | "list";
|
||||
onViewModeChange: (mode: "card" | "list") => void;
|
||||
}
|
||||
|
||||
export function ViewToggle({ viewMode, onViewModeChange }: ViewToggleProps) {
|
||||
const { t } = useTranslation('viewToggle');
|
||||
const { t } = useTranslation("viewToggle");
|
||||
|
||||
return (
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="flex items-center space-x-1 bg-muted rounded-lg p-1">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="bg-muted flex items-center space-x-1 rounded-lg p-1">
|
||||
<Button
|
||||
onClick={() => onViewModeChange('card')}
|
||||
variant={viewMode === 'card' ? 'default' : 'ghost'}
|
||||
onClick={() => onViewModeChange("card")}
|
||||
variant={viewMode === "card" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className={`flex items-center space-x-2 ${
|
||||
viewMode === 'card'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
viewMode === "card"
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Grid3X3 className="h-4 w-4" />
|
||||
<span className="text-sm">{t('cardView')}</span>
|
||||
<span className="text-sm">{t("cardView")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onViewModeChange('list')}
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
onClick={() => onViewModeChange("list")}
|
||||
variant={viewMode === "list" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className={`flex items-center space-x-2 ${
|
||||
viewMode === 'list'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
viewMode === "list"
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
<span className="text-sm">{t('listView')}</span>
|
||||
<span className="text-sm">{t("listView")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user