Compare commits

...

8 Commits

Author SHA1 Message Date
CanbiZ
9e1975dd1d 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.
2025-10-20 20:03:38 +02:00
CanbiZ
fc6e13946d feat(i18n): Lokalisierung - Phase 6 abgeschlossen (ExecutionModeModal, PublicKeyModal)
Lokalisierte Komponenten (19/alle):
- ExecutionModeModal: Server-Auswahl, Installation-Bestätigung, Single/Multiple Server Views
- PublicKeyModal: SSH Key Anzeige, Kopier-Funktionen, Anleitungen

Neue Translation Keys:
- executionModeModal.* (13+ keys für Loading, Server-Auswahl, Actions, Errors)
- publicKeyModal.* (10+ keys für Instructions, Labels, Actions, Fallbacks)

Technische Details:
- ExecutionModeModal: Conditional rendering für 0/1/N Server mit dynamic scriptName interpolation
- PublicKeyModal: Lokalisierte Fallback-Alerts für Copy-Fehler
- useEffect eslint-disable für fetchServers dependency
- Script name interpolation in Bestätigungs-Texten
2025-10-20 19:59:04 +02:00
CanbiZ
f16f0e58cd feat(i18n): Lokalisierung - Phase 5 abgeschlossen (ScriptCard, Badge, Server/Settings Buttons)
Lokalisierte Komponenten (17/alle):
- ScriptCard: Unbenanntes Skript, Download-Status, Beschreibung, Webseite-Link
- Badge: UpdateableBadge, PrivilegedBadge mit useTranslation in Convenience-Komponenten
- ServerSettingsButton: PVE-Server verwalten Button
- SettingsButton: Anwendungseinstellungen Button

Neue Translation Keys:
- scriptCard.unnamedScript, downloaded, notDownloaded, noDescription, website
- badge.updateable, privileged
- serverSettingsButton.description, buttonTitle, buttonLabel
- settingsButton.description, buttonTitle, buttonLabel

Technische Details:
- Badge: useTranslation nur in Convenience-Komponenten (UpdateableBadge, PrivilegedBadge)
- ScriptCard: Download-Status mit Fallback für fehlende Namen/Beschreibungen
- Konsistente Button-Beschreibungen für Server und App Settings
2025-10-20 19:49:37 +02:00
CanbiZ
dd737a8bc7 feat(i18n): Lokalisierung - Phase 4 abgeschlossen (HelpButton, ResyncButton, ViewToggle)
Lokalisierte Komponenten (13/alle):
- HelpButton: Hilfe-Button mit 'Need help?' und Öffnen-Action
- ResyncButton: Sync-Button mit Fortschritts-Messages und letzter Sync-Zeit
- ViewToggle: Card/List View Umschalter

Neue Translation Keys:
- helpButton.needHelp, openHelp, help
- resyncButton.syncDescription, syncing, syncJsonFiles, helpTooltip, lastSync, messages.*
- viewToggle.cardView, listView

Technische Details:
- ResyncButton: Dynamische Error-Erkennung für DE/EN ('Fehler'/'Error')
- Zeit-Formatierung mit toLocaleTimeString() für lastSync
- ViewToggle: Einfache View-Mode-Labels
2025-10-20 19:08:41 +02:00
CanbiZ
8fb9936cd6 feat(i18n): Lokalisierung - Phase 3 abgeschlossen (LoadingModal, AuthModal, SetupModal)
Lokalisierte Komponenten (10/alle):
- LoadingModal: Simple loading spinner mit 'Processing' und 'Please wait...'
- AuthModal: Login-Dialog mit Benutzername/Passwort
- SetupModal: Initial Setup Wizard mit Toggle für Auth-Aktivierung

Neue Translation Keys:
- loadingModal.processing, pleaseWait
- authModal.title, description, username.*, password.*, error, actions.*
- setupModal.title, description, username.*, password.*, confirmPassword.*, enableAuth.*, errors.*, actions.*

Technische Details:
- Konditionale Beschreibungen basierend auf enableAuth-Status
- Fehler-Messages mit t() für i18n
- Alle Labels, Placeholders und Button-Texte lokalisiert
2025-10-20 19:05:40 +02:00
CanbiZ
e0d5a07d18 feat(i18n): Lokalisierung - Phase 2 abgeschlossen
VersionDisplay vollständig lokalisiert:
- Loading states (Loading..., Unknown version)
- Update-Buttons (Update Now/Updating... mit Desktop/Mobile variants)
- LoadingOverlay mit Server-Neustart-Meldungen
- Log-Streaming Messages (Update started, Complete, Reconnecting, etc.)
- Error states (Unable to check for updates)
- Success states (Up to date )

 Translation-Keys hinzugefügt:
- versionDisplay.* (loading, unknownVersion, unableToCheck, upToDate, releaseNotes, helpTooltip)
- versionDisplay.update.* (updateNow, updateNowShort, updating, updatingShort)
- versionDisplay.loadingOverlay.* (serverRestarting, updatingApplication, alle Messages)

 Features:
- useCallback für startReconnectAttempts (verhindert unnötige Re-renders)
- Dynamische Server-Restart-Messages während Updates
- Responsive Button-Texte (Desktop: 'Update Now', Mobile: 'Update')
- Mehrsprachige Release Notes Labels

 Fortschritt:
- 7 Komponenten vollständig lokalisiert
- Alle High-Impact UI-Elemente übersetzt
- Footer, CategorySidebar, FilterBar bereits lokalisiert
2025-10-20 18:56:55 +02:00
CanbiZ
946038a29d feat(i18n): Lokalisierung - Phase 1 abgeschlossen
Vollständig lokalisierte Komponenten:
- GeneralSettingsModal: Alle Tabs (General/GitHub/Auth), Settings, Messages
- ConfirmationModal: Type-to-confirm Dialoge mit Fallback-Buttons
- ErrorModal: Error/Success Modals mit Details

 Translation-Keys hinzugefügt:
- de.ts & en.ts: settings.* (theme, filters, colorCoding, github, auth)
- de.ts & en.ts: confirmationModal.* (typeToConfirm, placeholder)
- de.ts & en.ts: errorModal.* (detailsLabel, errorDetailsLabel)

 Features:
- useTranslation Hook mit values-Interpolation
- Dynamische Button-Texte (Confirm/Cancel mit Fallback)
- Mehrsprachige Fehlermeldungen und Erfolgs-Benachrichtigungen
- Theme- und Language-Switching unterstützt

 Bestehende Lokalisierungen beibehalten:
- Footer, CategorySidebar, FilterBar bereits lokalisiert
2025-10-20 17:27:50 +02:00
CanbiZ
e994f14d0a Add i18n support and language toggle components
Introduces internationalization (i18n) support with new translation files, a LanguageProvider, and useTranslation hook. Refactors CategorySidebar to use translations for labels and tooltips, and adds a LanguageToggle component. Updates related UI components to support localization.
2025-10-20 17:05:33 +02:00
33 changed files with 4152 additions and 1303 deletions

34
package-lock.json generated
View File

@@ -5015,6 +5015,12 @@
"node": ">=8"
}
},
"node_modules/nan": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
"license": "MIT"
},
"node_modules/browserslist": {
"version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
@@ -5049,6 +5055,16 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/node-pty": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"nan": "^2.17.0"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -9585,12 +9601,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nan": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -9719,16 +9729,6 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/node-pty": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"nan": "^2.17.0"
}
},
"node_modules/node-releases": {
"version": "2.0.23",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
@@ -13128,4 +13128,4 @@
}
}
}
}
}

View File

@@ -90,4 +90,4 @@
"overrides": {
"prismjs": "^1.30.0"
}
}
}

View File

@@ -1,21 +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 { 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) {
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);
@@ -25,44 +31,49 @@ export function AuthModal({ isOpen }: AuthModalProps) {
setError(null);
const success = await login(username, password);
if (!success) {
setError('Invalid username or password');
setError(t("error"));
}
setIsLoading(false);
};
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">Authentication Required</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">
Please enter your credentials to access the application.
<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">
Username
<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="Enter your username"
placeholder={t("username.placeholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoading}
@@ -73,15 +84,18 @@ export function AuthModal({ isOpen }: AuthModalProps) {
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-2">
Password
<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="Enter your password"
placeholder={t("password.placeholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
@@ -92,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>
@@ -103,7 +117,7 @@ export function AuthModal({ isOpen }: AuthModalProps) {
disabled={isLoading || !username.trim() || !password.trim()}
className="w-full"
>
{isLoading ? 'Signing In...' : 'Sign In'}
{isLoading ? t("actions.signingIn") : t("actions.signIn")}
</Button>
</form>
</div>

View File

@@ -1,93 +1,108 @@
'use client';
"use client";
import React from 'react';
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 '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 'status':
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 "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":
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();
}
@@ -96,45 +111,79 @@ 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 }) => (
<Badge variant="updateable" className={className}>
Updateable
</Badge>
);
export const UpdateableBadge = ({ className }: { className?: string }) => {
const { t } = useTranslation("badge");
return (
<Badge variant="updateable" className={className}>
{t("updateable")}
</Badge>
);
};
export const PrivilegedBadge = ({ className }: { className?: string }) => (
<Badge variant="privileged" className={className}>
Privileged
</Badge>
);
export const PrivilegedBadge = ({ className }: { className?: string }) => {
const { t } = useTranslation("badge");
return (
<Badge variant="privileged" className={className}>
{t("privileged")}
</Badge>
);
};
export const StatusBadge = ({ status, children, className }: { status: 'success' | 'failed' | 'in_progress'; children: React.ReactNode; className?: string }) => (
export const StatusBadge = ({
status,
children,
className,
}: {
status: "success" | "failed" | "in_progress";
children: React.ReactNode;
className?: string;
}) => (
<Badge variant="status" status={status} className={className}>
{children}
</Badge>
);
export const ExecutionModeBadge = ({ mode, children, className }: { mode: 'local' | 'ssh'; children: React.ReactNode; className?: string }) => (
export const ExecutionModeBadge = ({
mode,
children,
className,
}: {
mode: "local" | "ssh";
children: React.ReactNode;
className?: string;
}) => (
<Badge variant="execution-mode" executionMode={mode} className={className}>
{children}
</Badge>
);
export const NoteBadge = ({ noteType, children, className }: { noteType: 'info' | 'warning' | 'error'; children: React.ReactNode; className?: string }) => (
export const NoteBadge = ({
noteType,
children,
className,
}: {
noteType: "info" | "warning" | "error";
children: React.ReactNode;
className?: string;
}) => (
<Badge variant="note" noteType={noteType} className={className}>
{children}
</Badge>
);
);

View File

@@ -1,7 +1,8 @@
'use client';
"use client";
import { useState } from 'react';
import { ContextualHelpIcon } from './ContextualHelpIcon';
import { useState } from "react";
import { useTranslation } from "@/lib/i18n/useTranslation";
import { ContextualHelpIcon } from "./ContextualHelpIcon";
interface CategorySidebarProps {
categories: string[];
@@ -12,218 +13,509 @@ interface CategorySidebarProps {
}
// Icon mapping for categories
const CategoryIcon = ({ iconName, className = "w-5 h-5" }: { iconName: string; className?: string }) => {
const CategoryIcon = ({
iconName,
className = "w-5 h-5",
}: {
iconName: string;
className?: string;
}) => {
const iconMap: Record<string, React.ReactElement> = {
server: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
/>
</svg>
),
monitor: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
),
box: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
),
shield: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
),
"shield-check": (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
),
key: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1 0 21 9z" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1 0 21 9z"
/>
</svg>
),
archive: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
/>
</svg>
),
database: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"
/>
</svg>
),
"chart-bar": (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
),
template: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
/>
</svg>
),
"folder-open": (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
),
"document-text": (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
),
film: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2m0 0V1.5a.5.5 0 01.5-.5h1a.5.5 0 01.5.5V4m-3 0H9m3 0v16a1 1 0 01-1 1H8a1 1 0 01-1-1V4m6 0h2a2 2 0 012 2v12a2 2 0 01-2 2h-2V4z" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2m0 0V1.5a.5.5 0 01.5-.5h1a.5.5 0 01.5.5V4m-3 0H9m3 0v16a1 1 0 01-1 1H8a1 1 0 01-1-1V4m6 0h2a2 2 0 012 2v12a2 2 0 01-2 2h-2V4z"
/>
</svg>
),
download: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
),
"video-camera": (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
),
home: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
),
wifi: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"
/>
</svg>
),
"chat-alt": (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
),
clock: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
code: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
/>
</svg>
),
"external-link": (
<svg className={className} 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
className={className}
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>
),
sparkles: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"
/>
</svg>
),
"currency-dollar": (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
/>
</svg>
),
puzzle: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"
/>
</svg>
),
office: (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
),
};
return iconMap[iconName] ?? (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5a2 2 0 00-2-2h-4a2 2 0 00-2 2v12a4 4 0 004 4 4 4 0 004-4V5z" />
</svg>
return (
iconMap[iconName] ?? (
<svg
className={className}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5a2 2 0 00-2-2h-4a2 2 0 00-2 2v12a4 4 0 004 4 4 4 0 004-4V5z"
/>
</svg>
)
);
};
export function CategorySidebar({
categories,
categoryCounts,
totalScripts,
selectedCategory,
onCategorySelect
export function CategorySidebar({
categories,
categoryCounts,
totalScripts,
selectedCategory,
onCategorySelect,
}: CategorySidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const { t } = useTranslation("categorySidebar");
const formatCategoryLabel = (category: string) => {
const defaultLabel = category.replace(/[_-]/g, " ");
return t(`categories.${category}`, { fallback: defaultLabel });
};
const formatCategoryTooltip = (categoryLabel: string, count: number) =>
t("tooltips.category", { values: { category: categoryLabel, count } });
// Category to icon mapping (based on metadata.json)
const categoryIconMapping: Record<string, string> = {
'Proxmox & Virtualization': 'server',
'Operating Systems': 'monitor',
'Containers & Docker': 'box',
'Network & Firewall': 'shield',
'Adblock & DNS': 'shield-check',
'Authentication & Security': 'key',
'Backup & Recovery': 'archive',
'Databases': 'database',
'Monitoring & Analytics': 'chart-bar',
'Dashboards & Frontends': 'template',
'Files & Downloads': 'folder-open',
'Documents & Notes': 'document-text',
'Media & Streaming': 'film',
'*Arr Suite': 'download',
'NVR & Cameras': 'video-camera',
'IoT & Smart Home': 'home',
'ZigBee, Z-Wave & Matter': 'wifi',
'MQTT & Messaging': 'chat-alt',
'Automation & Scheduling': 'clock',
'AI / Coding & Dev-Tools': 'code',
'Webservers & Proxies': 'external-link',
'Bots & ChatOps': 'sparkles',
'Finance & Budgeting': 'currency-dollar',
'Gaming & Leisure': 'puzzle',
'Business & ERP': 'office',
'Miscellaneous': 'box'
"Proxmox & Virtualization": "server",
"Operating Systems": "monitor",
"Containers & Docker": "box",
"Network & Firewall": "shield",
"Adblock & DNS": "shield-check",
"Authentication & Security": "key",
"Backup & Recovery": "archive",
Databases: "database",
"Monitoring & Analytics": "chart-bar",
"Dashboards & Frontends": "template",
"Files & Downloads": "folder-open",
"Documents & Notes": "document-text",
"Media & Streaming": "film",
"*Arr Suite": "download",
"NVR & Cameras": "video-camera",
"IoT & Smart Home": "home",
"ZigBee, Z-Wave & Matter": "wifi",
"MQTT & Messaging": "chat-alt",
"Automation & Scheduling": "clock",
"AI / Coding & Dev-Tools": "code",
"Webservers & Proxies": "external-link",
"Bots & ChatOps": "sparkles",
"Finance & Budgeting": "currency-dollar",
"Gaming & Leisure": "puzzle",
"Business & ERP": "office",
Miscellaneous: "box",
};
// Sort categories by count (descending) and then alphabetically
const sortedCategories = categories
.map(category => [category, categoryCounts[category] ?? 0] as const)
.map((category) => [category, categoryCounts[category] ?? 0] as const)
.sort(([a, countA], [b, countB]) => {
if (countB !== countA) return countB - countA;
return a.localeCompare(b);
});
return (
<div className={`bg-card rounded-lg shadow-md border border-border transition-all duration-300 ${
isCollapsed ? 'w-16' : 'w-full lg:w-80'
}`}>
<div
className={`bg-card border-border rounded-lg border shadow-md transition-all duration-300 ${
isCollapsed ? "w-16" : "w-full lg:w-80"
}`}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="border-border flex items-center justify-between border-b p-4">
{!isCollapsed && (
<div className="flex items-center justify-between w-full">
<div className="flex w-full items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-foreground">Categories</h3>
<p className="text-sm text-muted-foreground">{totalScripts} Total scripts</p>
<h3 className="text-foreground text-lg font-semibold">
{t("headerTitle")}
</h3>
<p className="text-muted-foreground text-sm">
{t("totalScripts", { values: { count: totalScripts } })}
</p>
</div>
<ContextualHelpIcon section="available-scripts" tooltip="Help with categories" />
<ContextualHelpIcon
section="available-scripts"
tooltip={t("helpTooltip")}
/>
</div>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="p-2 rounded-lg hover:bg-muted transition-colors"
title={isCollapsed ? 'Expand categories' : 'Collapse categories'}
className="hover:bg-muted rounded-lg p-2 transition-colors"
title={isCollapsed ? t("actions.expand") : t("actions.collapse")}
>
<svg
className={`w-5 h-5 text-muted-foreground transition-transform ${
isCollapsed ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
<svg
className={`text-muted-foreground h-5 w-5 transition-transform ${
isCollapsed ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
</div>
@@ -235,24 +527,26 @@ export function CategorySidebar({
{/* "All Categories" option */}
<button
onClick={() => onCategorySelect(null)}
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
selectedCategory === null
? 'bg-primary/10 text-primary border border-primary/20'
: 'hover:bg-accent text-muted-foreground'
}`}
className={`flex w-full items-center justify-between rounded-lg p-3 text-left transition-colors ${
selectedCategory === null
? "bg-primary/10 text-primary border-primary/20 border"
: "hover:bg-accent text-muted-foreground"
}`}
>
<div className="flex items-center space-x-3">
<CategoryIcon
iconName="template"
className={`w-5 h-5 ${selectedCategory === null ? 'text-primary' : 'text-muted-foreground'}`}
<CategoryIcon
iconName="template"
className={`h-5 w-5 ${selectedCategory === null ? "text-primary" : "text-muted-foreground"}`}
/>
<span className="font-medium">All Categories</span>
<span className="font-medium">{t("all.label")}</span>
</div>
<span className={`text-sm px-2 py-1 rounded-full ${
selectedCategory === null
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}>
<span
className={`rounded-full px-2 py-1 text-sm ${
selectedCategory === null
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground"
}`}
>
{totalScripts}
</span>
</button>
@@ -260,31 +554,32 @@ export function CategorySidebar({
{/* Individual Categories */}
{sortedCategories.map(([category, count]) => {
const isSelected = selectedCategory === category;
const categoryLabel = formatCategoryLabel(category);
return (
<button
key={category}
onClick={() => onCategorySelect(category)}
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
className={`flex w-full items-center justify-between rounded-lg p-3 text-left transition-colors ${
isSelected
? 'bg-primary/10 text-primary border border-primary/20'
: 'hover:bg-accent text-muted-foreground'
? "bg-primary/10 text-primary border-primary/20 border"
: "hover:bg-accent text-muted-foreground"
}`}
>
<div className="flex items-center space-x-3">
<CategoryIcon
iconName={categoryIconMapping[category] ?? 'box'}
className={`w-5 h-5 ${isSelected ? 'text-primary' : 'text-muted-foreground'}`}
<CategoryIcon
iconName={categoryIconMapping[category] ?? "box"}
className={`h-5 w-5 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
/>
<span className="font-medium capitalize">
{category.replace(/[_-]/g, ' ')}
</span>
<span className="font-medium">{categoryLabel}</span>
</div>
<span className={`text-sm px-2 py-1 rounded-full ${
isSelected
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}>
<span
className={`rounded-full px-2 py-1 text-sm ${
isSelected
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground"
}`}
>
{count}
</span>
</button>
@@ -296,66 +591,71 @@ export function CategorySidebar({
{/* Collapsed state - show only icons with counters and tooltips */}
{isCollapsed && (
<div className="p-2 flex flex-row lg:flex-col space-x-2 lg:space-x-0 lg:space-y-2 overflow-x-auto lg:overflow-x-visible">
<div className="flex flex-row space-x-2 overflow-x-auto p-2 lg:flex-col lg:space-y-2 lg:space-x-0 lg:overflow-x-visible">
{/* "All Categories" option */}
<div className="group relative">
<button
onClick={() => onCategorySelect(null)}
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${
selectedCategory === null
? 'bg-primary/10 text-primary border border-primary/20'
: 'hover:bg-accent text-muted-foreground'
}`}
>
<CategoryIcon
iconName="template"
className={`w-5 h-5 ${selectedCategory === null ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
<span className={`text-xs mt-1 px-1 rounded ${
className={`relative flex h-12 w-12 flex-col items-center justify-center rounded-lg transition-colors ${
selectedCategory === null
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}>
? "bg-primary/10 text-primary border-primary/20 border"
: "hover:bg-accent text-muted-foreground"
}`}
>
<CategoryIcon
iconName="template"
className={`h-5 w-5 ${selectedCategory === null ? "text-primary" : "text-muted-foreground group-hover:text-foreground"}`}
/>
<span
className={`mt-1 rounded px-1 text-xs ${
selectedCategory === null
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground"
}`}
>
{totalScripts}
</span>
</button>
{/* Tooltip */}
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-popover text-popover-foreground text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
All Categories ({totalScripts})
<div className="bg-popover text-popover-foreground pointer-events-none absolute top-1/2 left-full z-50 ml-2 hidden -translate-y-1/2 transform rounded px-2 py-1 text-sm whitespace-nowrap opacity-0 transition-opacity group-hover:opacity-100 lg:block">
{t("all.tooltip", { values: { count: totalScripts } })}
</div>
</div>
{/* Individual Categories */}
{sortedCategories.map(([category, count]) => {
const isSelected = selectedCategory === category;
const categoryLabel = formatCategoryLabel(category);
return (
<div key={category} className="group relative">
<button
onClick={() => onCategorySelect(category)}
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${
className={`relative flex h-12 w-12 flex-col items-center justify-center rounded-lg transition-colors ${
isSelected
? 'bg-primary/10 text-primary border border-primary/20'
: 'hover:bg-accent text-muted-foreground'
? "bg-primary/10 text-primary border-primary/20 border"
: "hover:bg-accent text-muted-foreground"
}`}
>
<CategoryIcon
iconName={categoryIconMapping[category] ?? 'box'}
className={`w-5 h-5 ${isSelected ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
<CategoryIcon
iconName={categoryIconMapping[category] ?? "box"}
className={`h-5 w-5 ${isSelected ? "text-primary" : "text-muted-foreground group-hover:text-foreground"}`}
/>
<span className={`text-xs mt-1 px-1 rounded ${
isSelected
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
}`}>
<span
className={`mt-1 rounded px-1 text-xs ${
isSelected
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground"
}`}
>
{count}
</span>
</button>
{/* Tooltip */}
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-popover text-popover-foreground text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
{category} ({count})
<div className="bg-popover text-popover-foreground pointer-events-none absolute top-1/2 left-full z-50 ml-2 hidden -translate-y-1/2 transform rounded px-2 py-1 text-sm whitespace-nowrap opacity-0 transition-opacity group-hover:opacity-100 lg:block">
{formatCategoryTooltip(categoryLabel, count)}
</div>
</div>
);
@@ -364,4 +664,4 @@ export function CategorySidebar({
)}
</div>
);
}
}

View File

@@ -1,9 +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 { 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;
@@ -11,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;
@@ -25,14 +26,20 @@ export function ConfirmationModal({
message,
variant,
confirmText,
confirmButtonText = 'Confirm',
cancelButtonText = 'Cancel'
confirmButtonText,
cancelButtonText,
}: ConfirmationModalProps) {
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]);
useRegisterModal(isOpen, { id: 'confirmation-modal', allowEscape, onClose });
// Use provided button texts or fallback to translations
const finalConfirmText = confirmButtonText ?? tc("confirm");
const finalCancelText = cancelButtonText ?? tc("cancel");
useRegisterModal(isOpen, { id: "confirmation-modal", allowEscape, onClose });
if (!isOpen) return null;
const isConfirmEnabled = isDanger ? typedText === confirmText : true;
@@ -40,62 +47,74 @@ 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">
Type <code className="bg-muted px-2 py-1 rounded text-sm">{confirmText}</code> to confirm:
<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={`Type "${confirmText}" here`}
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"
size="default"
className="w-full sm:w-auto"
>
{cancelButtonText}
{finalCancelText}
</Button>
<Button
onClick={handleConfirm}
@@ -104,7 +123,7 @@ export function ConfirmationModal({
size="default"
className="w-full sm:w-auto"
>
{confirmButtonText}
{finalConfirmText}
</Button>
</div>
</div>

View File

@@ -1,9 +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 { 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;
@@ -11,7 +12,7 @@ interface ErrorModalProps {
title: string;
message: string;
details?: string;
type?: 'error' | 'success';
type?: "error" | "success";
}
export function ErrorModal({
@@ -20,9 +21,11 @@ export function ErrorModal({
title,
message,
details,
type = 'error'
type = "error",
}: ErrorModalProps) {
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) {
@@ -36,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' ? 'Details:' : 'Error Details:'}
<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>
@@ -78,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}>
Close
{tc("close")}
</Button>
</div>
</div>

View File

@@ -1,41 +1,38 @@
'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';
"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) {
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);
const [selectedServer, setSelectedServer] = useState<Server | null>(null);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
useEffect(() => {
if (isOpen) {
void fetchServers();
}
}, [isOpen]);
// Auto-select server when exactly one server is available
useEffect(() => {
if (isOpen && !loading && servers.length === 1) {
setSelectedServer(servers[0] ?? null);
}
}, [isOpen, loading, servers]);
// Refresh servers when settings modal closes
const handleSettingsModalClose = () => {
setSettingsModalOpen(false);
@@ -47,56 +44,78 @@ 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('Failed to fetch servers');
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 ?? '')
const sortedServers = (data as Server[]).sort((a, b) =>
(a.name ?? "").localeCompare(b.name ?? ""),
);
setServers(sortedServers);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
setError(err instanceof Error ? err.message : t("errors.fetchFailed"));
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isOpen) {
void fetchServers();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
// Auto-select server when exactly one server is available
useEffect(() => {
if (isOpen && !loading && servers.length === 1) {
setSelectedServer(servers[0] ?? null);
}
}, [isOpen, loading, servers]);
const handleExecute = () => {
if (!selectedServer) {
setError('Please select a server for SSH execution');
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">Select Server</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>
@@ -104,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">Loading servers...</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">No servers configured</p>
<p className="text-xs mt-1">Add servers in Settings to execute scripts</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"
>
Open Server Settings
{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">
Install Script Confirmation
<h3 className="text-foreground mb-2 text-lg font-medium">
{t("installConfirmation.title")}
</h3>
<p className="text-sm text-muted-foreground">
Do you want to install &quot;{scriptName}&quot; on the following server?
<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 ?? 'Unnamed Server'}
<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>
@@ -166,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"
>
Cancel
<Button onClick={onClose} variant="outline" size="default">
{t("actions.cancel")}
</Button>
<Button
onClick={handleExecute}
variant="default"
size="default"
>
Install
{t("actions.install")}
</Button>
</div>
</div>
@@ -186,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">
Select server to execute &quot;{scriptName}&quot;
<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">
Select Server
<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="Select a server..."
placeholder={t("multipleServers.placeholder")}
/>
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-3">
<Button
onClick={onClose}
variant="outline"
size="default"
>
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"
: ""
}
>
Run on Server
{t("actions.runOnServer")}
</Button>
</div>
</div>
@@ -230,9 +260,9 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
</div>
{/* Server Settings Modal */}
<SettingsModal
isOpen={settingsModalOpen}
onClose={handleSettingsModalClose}
<SettingsModal
isOpen={settingsModalOpen}
onClose={handleSettingsModalClose}
/>
</>
);

View File

@@ -1,9 +1,19 @@
"use client";
import React, { useState } from "react";
import { useState } from "react";
import { Button } from "./ui/button";
import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter } from "lucide-react";
import {
Package,
Monitor,
Wrench,
Server,
FileText,
Calendar,
RefreshCw,
Filter,
} from "lucide-react";
import { useTranslation } from "~/lib/i18n/useTranslation";
export interface FilterState {
searchQuery: string;
@@ -24,10 +34,10 @@ interface FilterBarProps {
}
const SCRIPT_TYPES = [
{ value: "ct", label: "LXC Container", Icon: Package },
{ value: "vm", label: "Virtual Machine", Icon: Monitor },
{ value: "addon", label: "Add-on", Icon: Wrench },
{ value: "pve", label: "PVE Host", Icon: Server },
{ value: "ct", labelKey: "types.options.ct", Icon: Package },
{ value: "vm", labelKey: "types.options.vm", Icon: Monitor },
{ value: "addon", labelKey: "types.options.addon", Icon: Wrench },
{ value: "pve", labelKey: "types.options.pve", Icon: Server },
];
export function FilterBar({
@@ -39,6 +49,7 @@ export function FilterBar({
saveFiltersEnabled = false,
isLoadingFilters = false,
}: FilterBarProps) {
const { t } = useTranslation("filterBar");
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
@@ -64,50 +75,55 @@ export function FilterBar({
filters.sortOrder !== "asc";
const getUpdatableButtonText = () => {
if (filters.showUpdatable === null) return "Updatable: All";
if (filters.showUpdatable === true)
return `Updatable: Yes (${updatableCount})`;
return "Updatable: No";
if (filters.showUpdatable === null) return t("updatable.all");
if (filters.showUpdatable === true) {
return t("updatable.yes", { values: { count: updatableCount } });
}
return t("updatable.no");
};
const getTypeButtonText = () => {
if (filters.selectedTypes.length === 0) return "All Types";
if (filters.selectedTypes.length === 0) return t("types.all");
if (filters.selectedTypes.length === 1) {
const type = SCRIPT_TYPES.find(
(t) => t.value === filters.selectedTypes[0],
);
return type?.label ?? filters.selectedTypes[0];
return type ? t(type.labelKey) : filters.selectedTypes[0];
}
return `${filters.selectedTypes.length} Types`;
return t("types.multiple", {
values: { count: filters.selectedTypes.length },
});
};
return (
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
<div className="border-border bg-card mb-6 rounded-lg border p-4 shadow-sm sm:p-6">
{/* Loading State */}
{isLoadingFilters && (
<div className="mb-4 flex items-center justify-center py-2">
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<span>Loading saved filters...</span>
<div className="text-muted-foreground flex items-center space-x-2 text-sm">
<div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent"></div>
<span>{t("loading")}</span>
</div>
</div>
)}
{/* Filter Header */}
{!isLoadingFilters && (
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
<h3 className="text-foreground text-lg font-medium">{t("header")}</h3>
<ContextualHelpIcon
section="available-scripts"
tooltip={t("helpTooltip")}
/>
</div>
)}
{/* Search Bar */}
<div className="mb-4">
<div className="relative max-w-md w-full">
<div className="relative w-full max-w-md">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
className="h-5 w-5 text-muted-foreground"
className="text-muted-foreground h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -122,17 +138,17 @@ export function FilterBar({
</div>
<input
type="text"
placeholder="Search scripts..."
placeholder={t("search.placeholder")}
value={filters.searchQuery}
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
className="border-input bg-background text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-primary block w-full rounded-lg border py-3 pr-10 pl-10 text-sm leading-5 focus:ring-2 focus:outline-none"
/>
{filters.searchQuery && (
<Button
onClick={() => updateFilters({ searchQuery: "" })}
variant="ghost"
size="icon"
className="absolute inset-y-0 right-0 pr-3 text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground absolute inset-y-0 right-0 pr-3"
>
<svg
className="h-5 w-5"
@@ -153,7 +169,7 @@ export function FilterBar({
</div>
{/* Filter Buttons */}
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
<div className="mb-4 flex flex-col flex-wrap gap-2 sm:flex-row sm:gap-3">
{/* Updateable Filter */}
<Button
onClick={() => {
@@ -167,12 +183,12 @@ export function FilterBar({
}}
variant="outline"
size="default"
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
className={`flex w-full items-center justify-center space-x-2 sm:w-auto ${
filters.showUpdatable === null
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: filters.showUpdatable === true
? "border border-success/20 bg-success/10 text-success"
: "border border-destructive/20 bg-destructive/10 text-destructive"
? "border-success/20 bg-success/10 text-success border"
: "border-destructive/20 bg-destructive/10 text-destructive border"
}`}
>
<RefreshCw className="h-4 w-4" />
@@ -185,10 +201,10 @@ export function FilterBar({
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
variant="outline"
size="default"
className={`w-full flex items-center justify-center space-x-2 ${
className={`flex w-full items-center justify-center space-x-2 ${
filters.selectedTypes.length === 0
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: "border border-primary/20 bg-primary/10 text-primary"
: "border-primary/20 bg-primary/10 text-primary border"
}`}
>
<Filter className="h-4 w-4" />
@@ -209,14 +225,14 @@ export function FilterBar({
</Button>
{isTypeDropdownOpen && (
<div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-border bg-card shadow-lg">
<div className="border-border bg-card absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border shadow-lg">
<div className="p-2">
{SCRIPT_TYPES.map((type) => {
const IconComponent = type.Icon;
return (
<label
key={type.value}
className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-accent"
className="hover:bg-accent flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2"
>
<input
type="checkbox"
@@ -237,17 +253,17 @@ export function FilterBar({
});
}
}}
className="rounded border-input text-primary focus:ring-primary"
className="border-input text-primary focus:ring-primary rounded"
/>
<IconComponent className="h-4 w-4" />
<span className="text-sm text-muted-foreground">
{type.label}
<span className="text-muted-foreground text-sm">
{t(type.labelKey)}
</span>
</label>
);
})}
</div>
<div className="border-t border-border p-2">
<div className="border-border border-t p-2">
<Button
onClick={() => {
updateFilters({ selectedTypes: [] });
@@ -255,9 +271,9 @@ export function FilterBar({
}}
variant="ghost"
size="sm"
className="w-full justify-start text-muted-foreground hover:bg-accent hover:text-foreground"
className="text-muted-foreground hover:bg-accent hover:text-foreground w-full justify-start"
>
Clear all
{t("actions.clearAllTypes")}
</Button>
</div>
</div>
@@ -270,14 +286,18 @@ export function FilterBar({
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
variant="outline"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
className="bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground flex w-full items-center justify-center space-x-2 sm:w-auto"
>
{filters.sortBy === "name" ? (
<FileText className="h-4 w-4" />
) : (
<Calendar className="h-4 w-4" />
)}
<span>{filters.sortBy === "name" ? "By Name" : "By Created Date"}</span>
<span>
{filters.sortBy === "name"
? t("sort.byName")
: t("sort.byCreated")}
</span>
<svg
className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
fill="none"
@@ -294,31 +314,35 @@ export function FilterBar({
</Button>
{isSortDropdownOpen && (
<div className="absolute top-full left-0 z-10 mt-1 w-full sm:w-48 rounded-lg border border-border bg-card shadow-lg">
<div className="border-border bg-card absolute top-full left-0 z-10 mt-1 w-full rounded-lg border shadow-lg sm:w-48">
<div className="p-2">
<button
onClick={() => {
updateFilters({ sortBy: "name" });
setIsSortDropdownOpen(false);
}}
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
filters.sortBy === "name" ? "bg-primary/10 text-primary" : "text-muted-foreground"
className={`hover:bg-accent flex w-full items-center space-x-3 rounded-md px-3 py-2 text-left ${
filters.sortBy === "name"
? "bg-primary/10 text-primary"
: "text-muted-foreground"
}`}
>
<FileText className="h-4 w-4" />
<span className="text-sm">By Name</span>
<span className="text-sm">{t("sort.byName")}</span>
</button>
<button
onClick={() => {
updateFilters({ sortBy: "created" });
setIsSortDropdownOpen(false);
}}
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
filters.sortBy === "created" ? "bg-primary/10 text-primary" : "text-muted-foreground"
className={`hover:bg-accent flex w-full items-center space-x-3 rounded-md px-3 py-2 text-left ${
filters.sortBy === "created"
? "bg-primary/10 text-primary"
: "text-muted-foreground"
}`}
>
<Calendar className="h-4 w-4" />
<span className="text-sm">By Created Date</span>
<span className="text-sm">{t("sort.byCreated")}</span>
</button>
</div>
</div>
@@ -334,7 +358,7 @@ export function FilterBar({
}
variant="outline"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-1 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
className="bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground flex w-full items-center justify-center space-x-1 sm:w-auto"
>
{filters.sortOrder === "asc" ? (
<>
@@ -352,7 +376,9 @@ export function FilterBar({
/>
</svg>
<span>
{filters.sortBy === "created" ? "Oldest First" : "A-Z"}
{filters.sortBy === "created"
? t("sort.oldestFirst")
: t("sort.aToZ")}
</span>
</>
) : (
@@ -371,7 +397,9 @@ export function FilterBar({
/>
</svg>
<span>
{filters.sortBy === "created" ? "Newest First" : "Z-A"}
{filters.sortBy === "created"
? t("sort.newestFirst")
: t("sort.zToA")}
</span>
</>
)}
@@ -379,30 +407,38 @@ export function FilterBar({
</div>
{/* Filter Summary and Clear All */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<div className="flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center">
<div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground">
<div className="text-muted-foreground text-sm">
{filteredCount === totalScripts ? (
<span>Showing all {totalScripts} scripts</span>
<span>
{t("summary.showingAll", { values: { count: totalScripts } })}
</span>
) : (
<span>
{filteredCount} of {totalScripts} scripts{" "}
{t("summary.showingFiltered", {
values: { filtered: filteredCount, total: totalScripts },
})}{" "}
{hasActiveFilters && (
<span className="font-medium text-info">
(filtered)
<span className="text-info font-medium">
{t("summary.filteredSuffix")}
</span>
)}
</span>
)}
</div>
{/* Filter Persistence Status */}
{!isLoadingFilters && saveFiltersEnabled && (
<div className="flex items-center space-x-1 text-xs text-success">
<div className="text-success flex items-center space-x-1 text-xs">
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span>Filters are being saved automatically</span>
<span>{t("persistence.enabled")}</span>
</div>
)}
</div>
@@ -412,7 +448,7 @@ export function FilterBar({
onClick={clearAllFilters}
variant="ghost"
size="sm"
className="flex items-center space-x-1 text-error hover:bg-error/10 hover:text-error-foreground w-full sm:w-auto justify-center sm:justify-start"
className="text-error hover:bg-error/10 hover:text-error-foreground flex w-full items-center justify-center space-x-1 sm:w-auto sm:justify-start"
>
<svg
className="h-4 w-4"
@@ -427,7 +463,7 @@ export function FilterBar({
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<span>Clear all filters</span>
<span>{t("actions.clearFilters")}</span>
</Button>
)}
</div>

View File

@@ -1,8 +1,9 @@
'use client';
"use client";
import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { ExternalLink, FileText } from 'lucide-react';
import { api } from "~/trpc/react";
import { Button } from "./ui/button";
import { ExternalLink, FileText } from "lucide-react";
import { useTranslation } from "~/lib/i18n/useTranslation";
interface FooterProps {
onOpenReleaseNotes: () => void;
@@ -10,41 +11,43 @@ interface FooterProps {
export function Footer({ onOpenReleaseNotes }: FooterProps) {
const { data: versionData } = api.version.getCurrentVersion.useQuery();
const { t } = useTranslation("footer");
const currentYear = new Date().getFullYear();
return (
<footer className="sticky bottom-0 mt-auto border-t border-border bg-muted/30 py-3 backdrop-blur-sm">
<footer className="border-border bg-muted/30 sticky bottom-0 mt-auto border-t py-3 backdrop-blur-sm">
<div className="container mx-auto px-4">
<div className="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-muted-foreground">
<div className="text-muted-foreground flex flex-col items-center justify-between gap-2 text-sm sm:flex-row">
<div className="flex items-center gap-2">
<span>© 2024 PVE Scripts Local</span>
<span>{t("copyright", { values: { year: currentYear } })}</span>
{versionData?.success && versionData.version && (
<Button
variant="ghost"
size="sm"
onClick={onOpenReleaseNotes}
className="h-auto p-1 text-xs hover:text-foreground"
className="hover:text-foreground h-auto p-1 text-xs"
>
v{versionData.version}
</Button>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={onOpenReleaseNotes}
className="h-auto p-2 text-xs hover:text-foreground"
className="hover:text-foreground h-auto p-2 text-xs"
>
<FileText className="h-3 w-3 mr-1" />
Release Notes
<FileText className="mr-1 h-3 w-3" />
{t("releaseNotes")}
</Button>
<Button
variant="ghost"
size="sm"
asChild
className="h-auto p-2 text-xs hover:text-foreground"
className="hover:text-foreground h-auto p-2 text-xs"
>
<a
href="https://github.com/community-scripts/ProxmoxVE-Local"
@@ -53,7 +56,7 @@ export function Footer({ onOpenReleaseNotes }: FooterProps) {
className="flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
GitHub
{t("github")}
</a>
</Button>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,765 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Toggle } from "./ui/toggle";
import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { useTheme } from "./ThemeProvider";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useTranslation } from "~/lib/i18n/useTranslation";
interface GeneralSettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
export function GeneralSettingsModal({
isOpen,
onClose,
}: GeneralSettingsModalProps) {
useRegisterModal(isOpen, {
id: "general-settings-modal",
allowEscape: true,
onClose,
});
const { t, locale, setLocale, availableLocales } = useTranslation("settings");
const { theme, setTheme } = useTheme();
const [activeTab, setActiveTab] = useState<"general" | "github" | "auth">(
"general",
);
const [githubToken, setGithubToken] = useState("");
const [saveFilter, setSaveFilter] = useState(false);
const [savedFilters, setSavedFilters] = useState<any>(null);
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
// Auth state
const [authUsername, setAuthUsername] = useState("");
const [authPassword, setAuthPassword] = useState("");
const [authConfirmPassword, setAuthConfirmPassword] = useState("");
const [authEnabled, setAuthEnabled] = useState(false);
const [authHasCredentials, setAuthHasCredentials] = useState(false);
const [authSetupCompleted, setAuthSetupCompleted] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
// Load existing settings when modal opens
useEffect(() => {
if (isOpen) {
void loadGithubToken();
void loadSaveFilter();
void loadSavedFilters();
void loadAuthCredentials();
void loadColorCodingSetting();
}
}, [isOpen]);
const loadGithubToken = async () => {
setIsLoading(true);
try {
const response = await fetch("/api/settings/github-token");
if (response.ok) {
const data = await response.json();
setGithubToken((data.token as string) ?? "");
}
} catch (error) {
console.error("Error loading GitHub token:", error);
} finally {
setIsLoading(false);
}
};
const loadSaveFilter = async () => {
try {
const response = await fetch("/api/settings/save-filter");
if (response.ok) {
const data = await response.json();
setSaveFilter((data.enabled as boolean) ?? false);
}
} catch (error) {
console.error("Error loading save filter setting:", error);
}
};
const saveSaveFilter = async (enabled: boolean) => {
try {
const response = await fetch("/api/settings/save-filter", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ enabled }),
});
if (response.ok) {
setSaveFilter(enabled);
setMessage({ type: "success", text: "Save filter setting updated!" });
// If disabling save filters, clear saved filters
if (!enabled) {
await clearSavedFilters();
}
} else {
const errorData = await response.json();
setMessage({
type: "error",
text: errorData.error ?? "Failed to save setting",
});
}
} catch {
setMessage({ type: "error", text: "Failed to save setting" });
}
};
const loadSavedFilters = async () => {
try {
const response = await fetch("/api/settings/filters");
if (response.ok) {
const data = await response.json();
setSavedFilters(data.filters);
}
} catch (error) {
console.error("Error loading saved filters:", error);
}
};
const clearSavedFilters = async () => {
try {
const response = await fetch("/api/settings/filters", {
method: "DELETE",
});
if (response.ok) {
setSavedFilters(null);
setMessage({ type: "success", text: "Saved filters cleared!" });
} else {
const errorData = await response.json();
setMessage({
type: "error",
text: errorData.error ?? "Failed to clear filters",
});
}
} catch {
setMessage({ type: "error", text: "Failed to clear filters" });
}
};
const saveGithubToken = async () => {
setIsSaving(true);
setMessage(null);
try {
const response = await fetch("/api/settings/github-token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token: githubToken }),
});
if (response.ok) {
setMessage({
type: "success",
text: "GitHub token saved successfully!",
});
} else {
const errorData = await response.json();
setMessage({
type: "error",
text: errorData.error ?? "Failed to save token",
});
}
} catch {
setMessage({ type: "error", text: "Failed to save token" });
} finally {
setIsSaving(false);
}
};
const loadColorCodingSetting = async () => {
try {
const response = await fetch("/api/settings/color-coding");
if (response.ok) {
const data = await response.json();
setColorCodingEnabled(Boolean(data.enabled));
}
} catch (error) {
console.error("Error loading color coding setting:", error);
}
};
const saveColorCodingSetting = async (enabled: boolean) => {
try {
const response = await fetch("/api/settings/color-coding", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ enabled }),
});
if (response.ok) {
setColorCodingEnabled(enabled);
setMessage({
type: "success",
text: "Color coding setting saved successfully",
});
setTimeout(() => setMessage(null), 3000);
} else {
setMessage({
type: "error",
text: "Failed to save color coding setting",
});
setTimeout(() => setMessage(null), 3000);
}
} catch (error) {
console.error("Error saving color coding setting:", error);
setMessage({
type: "error",
text: "Failed to save color coding setting",
});
setTimeout(() => setMessage(null), 3000);
}
};
const loadAuthCredentials = async () => {
setAuthLoading(true);
try {
const response = await fetch("/api/settings/auth-credentials");
if (response.ok) {
const data = (await response.json()) as {
username: string;
enabled: boolean;
hasCredentials: boolean;
setupCompleted: boolean;
};
setAuthUsername(data.username ?? "");
setAuthEnabled(data.enabled ?? false);
setAuthHasCredentials(data.hasCredentials ?? false);
setAuthSetupCompleted(data.setupCompleted ?? false);
}
} catch (error) {
console.error("Error loading auth credentials:", error);
} finally {
setAuthLoading(false);
}
};
const saveAuthCredentials = async () => {
if (authPassword !== authConfirmPassword) {
setMessage({ type: "error", text: "Passwords do not match" });
return;
}
setAuthLoading(true);
setMessage(null);
try {
const response = await fetch("/api/settings/auth-credentials", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: authUsername,
password: authPassword,
enabled: authEnabled,
}),
});
if (response.ok) {
setMessage({
type: "success",
text: "Authentication credentials updated successfully!",
});
setAuthPassword("");
setAuthConfirmPassword("");
void loadAuthCredentials();
} else {
const errorData = await response.json();
setMessage({
type: "error",
text: errorData.error ?? "Failed to save credentials",
});
}
} catch {
setMessage({ type: "error", text: "Failed to save credentials" });
} finally {
setAuthLoading(false);
}
};
const toggleAuthEnabled = async (enabled: boolean) => {
setAuthLoading(true);
setMessage(null);
try {
const response = await fetch("/api/settings/auth-credentials", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ enabled }),
});
if (response.ok) {
setAuthEnabled(enabled);
setMessage({
type: "success",
text: `Authentication ${enabled ? "enabled" : "disabled"} successfully!`,
});
} else {
const errorData = await response.json();
setMessage({
type: "error",
text: errorData.error ?? "Failed to update auth status",
});
}
} catch {
setMessage({ type: "error", text: "Failed to update auth status" });
} finally {
setAuthLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-2 backdrop-blur-sm sm:p-4">
<div className="bg-card max-h-[95vh] w-full max-w-4xl overflow-hidden rounded-lg shadow-xl sm:max-h-[90vh]">
{/* Header */}
<div className="border-border flex items-center justify-between border-b p-4 sm:p-6">
<div className="flex items-center gap-2">
<h2 className="text-card-foreground text-xl font-bold sm:text-2xl">
Settings
</h2>
<ContextualHelpIcon
section="general-settings"
tooltip="Help with General Settings"
/>
</div>
<Button
onClick={onClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg
className="h-5 w-5 sm:h-6 sm: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>
{/* Tabs */}
<div className="border-border border-b">
<nav className="flex flex-col space-y-1 px-4 sm:flex-row sm:space-y-0 sm:space-x-8 sm:px-6">
<Button
onClick={() => setActiveTab("general")}
variant="ghost"
size="null"
className={`w-full border-b-2 px-1 py-3 text-sm font-medium sm:w-auto sm:py-4 ${
activeTab === "general"
? "border-primary text-primary"
: "text-muted-foreground hover:text-foreground hover:border-border border-transparent"
}`}
>
General
</Button>
<Button
onClick={() => setActiveTab("github")}
variant="ghost"
size="null"
className={`w-full border-b-2 px-1 py-3 text-sm font-medium sm:w-auto sm:py-4 ${
activeTab === "github"
? "border-primary text-primary"
: "text-muted-foreground hover:text-foreground hover:border-border border-transparent"
}`}
>
GitHub
</Button>
<Button
onClick={() => setActiveTab("auth")}
variant="ghost"
size="null"
className={`w-full border-b-2 px-1 py-3 text-sm font-medium sm:w-auto sm:py-4 ${
activeTab === "auth"
? "border-primary text-primary"
: "text-muted-foreground hover:text-foreground hover:border-border border-transparent"
}`}
>
Authentication
</Button>
</nav>
</div>
{/* Content */}
<div className="max-h-[calc(95vh-180px)] overflow-y-auto p-4 sm:max-h-[calc(90vh-200px)] sm:p-6">
{activeTab === "general" && (
<div className="space-y-4 sm:space-y-6">
<h3 className="text-foreground mb-3 text-base font-medium sm:mb-4 sm:text-lg">
General Settings
</h3>
<p className="text-muted-foreground mb-4 text-sm sm:text-base">
Configure general application preferences and behavior.
</p>
<div className="space-y-4">
<div className="border-border rounded-lg border p-4">
<h4 className="text-foreground mb-2 font-medium">Theme</h4>
<p className="text-muted-foreground mb-4 text-sm">
Choose your preferred color theme for the application.
</p>
<div className="flex items-center justify-between">
<div>
<p className="text-foreground text-sm font-medium">
Current Theme
</p>
<p className="text-muted-foreground text-xs">
{theme === "light" ? "Light mode" : "Dark mode"}
</p>
</div>
<div className="flex gap-2">
<Button
onClick={() => setTheme("light")}
variant={theme === "light" ? "default" : "outline"}
size="sm"
>
Light
</Button>
<Button
onClick={() => setTheme("dark")}
variant={theme === "dark" ? "default" : "outline"}
size="sm"
>
Dark
</Button>
</div>
</div>
</div>
<div className="border-border rounded-lg border p-4">
<h4 className="text-foreground mb-2 font-medium">
Save Filters
</h4>
<p className="text-muted-foreground mb-4 text-sm">
Save your configured script filters.
</p>
<Toggle
checked={saveFilter}
onCheckedChange={saveSaveFilter}
label="Enable filter saving"
/>
{saveFilter && (
<div className="bg-muted mt-4 rounded-lg p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-foreground text-sm font-medium">
Saved Filters
</p>
<p className="text-muted-foreground text-xs">
{savedFilters
? "Filters are currently saved"
: "No filters saved yet"}
</p>
{savedFilters && (
<div className="text-muted-foreground mt-2 text-xs">
<div>
Search: {savedFilters.searchQuery ?? "None"}
</div>
<div>
Types: {savedFilters.selectedTypes?.length ?? 0}{" "}
selected
</div>
<div>
Sort: {savedFilters.sortBy} (
{savedFilters.sortOrder})
</div>
</div>
)}
</div>
{savedFilters && (
<Button
onClick={clearSavedFilters}
variant="outline"
size="sm"
className="text-error hover:text-error/80"
>
Clear
</Button>
)}
</div>
</div>
)}
</div>
<div className="border-border rounded-lg border p-4">
<h4 className="text-foreground mb-2 font-medium">
Server Color Coding
</h4>
<p className="text-muted-foreground mb-4 text-sm">
Enable color coding for servers to visually distinguish them
throughout the application.
</p>
<Toggle
checked={colorCodingEnabled}
onCheckedChange={saveColorCodingSetting}
label="Enable server color coding"
/>
</div>
</div>
</div>
)}
{activeTab === "github" && (
<div className="space-y-4 sm:space-y-6">
<div>
<h3 className="text-foreground mb-3 text-base font-medium sm:mb-4 sm:text-lg">
GitHub Integration
</h3>
<p className="text-muted-foreground mb-4 text-sm sm:text-base">
Configure GitHub integration for script management and
updates.
</p>
<div className="space-y-4">
<div className="border-border rounded-lg border p-4">
<h4 className="text-foreground mb-2 font-medium">
GitHub Personal Access Token
</h4>
<p className="text-muted-foreground mb-4 text-sm">
Save a GitHub Personal Access Token to circumvent GitHub
API rate limits.
</p>
<div className="space-y-3">
<div>
<label
htmlFor="github-token"
className="text-foreground mb-1 block text-sm font-medium"
>
Token
</label>
<Input
id="github-token"
type="password"
placeholder="Enter your GitHub Personal Access Token"
value={githubToken}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setGithubToken(e.target.value)
}
disabled={isLoading || isSaving}
className="w-full"
/>
</div>
{message && (
<div
className={`rounded-md p-3 text-sm ${
message.type === "success"
? "bg-success/10 text-success-foreground border-success/20 border"
: "bg-error/10 text-error-foreground border-error/20 border"
}`}
>
{message.text}
</div>
)}
<div className="flex gap-2">
<Button
onClick={saveGithubToken}
disabled={
isSaving || isLoading || !githubToken.trim()
}
className="flex-1"
>
{isSaving ? "Saving..." : "Save Token"}
</Button>
<Button
onClick={loadGithubToken}
disabled={isLoading || isSaving}
variant="outline"
>
{isLoading ? "Loading..." : "Refresh"}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === "auth" && (
<div className="space-y-4 sm:space-y-6">
<div>
<h3 className="text-foreground mb-3 text-base font-medium sm:mb-4 sm:text-lg">
Authentication Settings
</h3>
<p className="text-muted-foreground mb-4 text-sm sm:text-base">
Configure authentication to secure access to your application.
</p>
<div className="space-y-4">
<div className="border-border rounded-lg border p-4">
<h4 className="text-foreground mb-2 font-medium">
Authentication Status
</h4>
<p className="text-muted-foreground mb-4 text-sm">
{authSetupCompleted
? authHasCredentials
? `Authentication is ${authEnabled ? "enabled" : "disabled"}. Current username: ${authUsername}`
: `Authentication is ${authEnabled ? "enabled" : "disabled"}. No credentials configured.`
: "Authentication setup has not been completed yet."}
</p>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-foreground text-sm font-medium">
Enable Authentication
</p>
<p className="text-muted-foreground text-xs">
{authEnabled
? "Authentication is required on every page load"
: "Authentication is optional"}
</p>
</div>
<Toggle
checked={authEnabled}
onCheckedChange={toggleAuthEnabled}
disabled={authLoading || !authSetupCompleted}
label="Enable authentication"
/>
</div>
</div>
</div>
<div className="border-border rounded-lg border p-4">
<h4 className="text-foreground mb-2 font-medium">
Update Credentials
</h4>
<p className="text-muted-foreground mb-4 text-sm">
Change your username and password for authentication.
</p>
<div className="space-y-3">
<div>
<label
htmlFor="auth-username"
className="text-foreground mb-1 block text-sm font-medium"
>
Username
</label>
<Input
id="auth-username"
type="text"
placeholder="Enter username"
value={authUsername}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setAuthUsername(e.target.value)
}
disabled={authLoading}
className="w-full"
minLength={3}
/>
</div>
<div>
<label
htmlFor="auth-password"
className="text-foreground mb-1 block text-sm font-medium"
>
New Password
</label>
<Input
id="auth-password"
type="password"
placeholder="Enter new password"
value={authPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setAuthPassword(e.target.value)
}
disabled={authLoading}
className="w-full"
minLength={6}
/>
</div>
<div>
<label
htmlFor="auth-confirm-password"
className="text-foreground mb-1 block text-sm font-medium"
>
Confirm Password
</label>
<Input
id="auth-confirm-password"
type="password"
placeholder="Confirm new password"
value={authConfirmPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setAuthConfirmPassword(e.target.value)
}
disabled={authLoading}
className="w-full"
minLength={6}
/>
</div>
{message && (
<div
className={`rounded-md p-3 text-sm ${
message.type === "success"
? "bg-success/10 text-success-foreground border-success/20 border"
: "bg-error/10 text-error-foreground border-error/20 border"
}`}
>
{message.text}
</div>
)}
<div className="flex gap-2">
<Button
onClick={saveAuthCredentials}
disabled={
authLoading ||
!authUsername.trim() ||
!authPassword.trim() ||
!authConfirmPassword.trim()
}
className="flex-1"
>
{authLoading ? "Saving..." : "Update Credentials"}
</Button>
<Button
onClick={loadAuthCredentials}
disabled={authLoading}
variant="outline"
>
{authLoading ? "Loading..." : "Refresh"}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,38 +1,40 @@
'use client';
"use client";
import { useState } from 'react';
import { HelpModal } from './HelpModal';
import { Button } from './ui/button';
import { HelpCircle } from 'lucide-react';
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 [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">
Need help?
<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="Open Help"
title={t("openHelp")}
>
<HelpCircle className="w-5 h-5 mr-2" />
Help
<HelpCircle className="mr-2 h-5 w-5" />
{t("help")}
</Button>
</div>
<HelpModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
<HelpModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
initialSection={initialSection}
/>
</>

View File

@@ -0,0 +1,50 @@
"use client";
import { useCallback } from "react";
import { Languages } from "lucide-react";
import { useTranslation } from "~/lib/i18n/useTranslation";
import { type Locale, locales } from "~/lib/i18n/config";
import { Button } from "./ui/button";
interface LanguageToggleProps {
className?: string;
showLabel?: boolean;
}
function getNextLocale(current: Locale): Locale {
const orderedLocales = [...locales];
const currentIndex = orderedLocales.indexOf(current);
const nextIndex =
currentIndex === -1 ? 0 : (currentIndex + 1) % orderedLocales.length;
const fallback = orderedLocales[0] ?? current;
return orderedLocales[nextIndex] ?? fallback;
}
export function LanguageToggle({
className = "",
showLabel = false,
}: LanguageToggleProps) {
const { locale, setLocale, t } = useTranslation("common.language");
const nextLocale = getNextLocale(locale);
const handleToggle = useCallback(() => {
setLocale(nextLocale);
}, [nextLocale, setLocale]);
return (
<Button
variant="ghost"
size="icon"
onClick={handleToggle}
className={`text-muted-foreground hover:text-foreground transition-colors ${className}`}
aria-label={t("switch")}
>
<Languages className="h-4 w-4" />
{showLabel && (
<span className="ml-2 text-sm">{locale.toUpperCase()}</span>
)}
</Button>
);
}

View File

@@ -1,7 +1,8 @@
'use client';
"use client";
import { Loader2 } from 'lucide-react';
import { useRegisterModal } from './modal/ModalStackProvider';
import { Loader2 } from "lucide-react";
import { useRegisterModal } from "./modal/ModalStackProvider";
import { useTranslation } from "@/lib/i18n/useTranslation";
interface LoadingModalProps {
isOpen: boolean;
@@ -9,26 +10,29 @@ interface LoadingModalProps {
}
export function LoadingModal({ isOpen, action }: LoadingModalProps) {
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">
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">
Please wait...
<p className="text-muted-foreground text-sm">{action}</p>
<p className="text-muted-foreground mt-2 text-xs">
{t("pleaseWait")}
</p>
</div>
</div>
@@ -36,4 +40,3 @@ export function LoadingModal({ isOpen, action }: LoadingModalProps) {
</div>
);
}

View File

@@ -1,9 +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 { 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;
@@ -13,8 +14,19 @@ interface PublicKeyModalProps {
serverIp: string;
}
export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverIp }: PublicKeyModalProps) {
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);
@@ -29,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('Please manually copy this key:\n\n' + 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('Please manually copy this key:\n\n' + publicKey);
alert(t("copyFallback") + publicKey);
}
};
@@ -67,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('Please manually copy this command:\n\n' + 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('Please manually copy this command:\n\n' + 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">SSH Public Key</h2>
<p className="text-sm text-muted-foreground">Add this key to your server&apos;s authorized_keys</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
@@ -118,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>
@@ -133,19 +147,39 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
{/* Instructions */}
<div className="space-y-2">
<h3 className="font-medium text-foreground">Instructions:</h3>
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
<li>Copy the public key below</li>
<li>SSH into your server: <code className="bg-muted px-1 rounded">ssh root@{serverIp}</code></li>
<li>Add the key to authorized_keys: <code className="bg-muted px-1 rounded">echo &quot;&lt;paste-key&gt;&quot; &gt;&gt; ~/.ssh/authorized_keys</code></li>
<li>Set proper permissions: <code className="bg-muted px-1 rounded">chmod 600 ~/.ssh/authorized_keys</code></li>
<h3 className="text-foreground font-medium">
{t("instructions.title")}
</h3>
<ol className="text-muted-foreground list-inside list-decimal space-y-1 text-sm">
<li>{t("instructions.step1")}</li>
<li>
{t("instructions.step2")}{" "}
<code className="bg-muted rounded px-1">
ssh root@{serverIp}
</code>
</li>
<li>
{t("instructions.step3")}{" "}
<code className="bg-muted rounded px-1">
echo &quot;&lt;paste-key&gt;&quot; &gt;&gt;
~/.ssh/authorized_keys
</code>
</li>
<li>
{t("instructions.step4")}{" "}
<code className="bg-muted rounded px-1">
chmod 600 ~/.ssh/authorized_keys
</code>
</li>
</ol>
</div>
{/* Public Key */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">Public Key:</label>
<label className="text-foreground text-sm font-medium">
{t("publicKeyLabel")}
</label>
<Button
variant="outline"
size="sm"
@@ -155,12 +189,12 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
{copied ? (
<>
<Check className="h-4 w-4" />
Copied!
{t("actions.copied")}
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
{t("actions.copy")}
</>
)}
</Button>
@@ -168,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="Public key will appear here..."
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">Quick Add Command:</label>
<label className="text-foreground text-sm font-medium">
{t("quickCommandLabel")}
</label>
<Button
variant="outline"
size="sm"
@@ -186,30 +222,30 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
{commandCopied ? (
<>
<Check className="h-4 w-4" />
Copied!
{t("actions.copied")}
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy Command
{t("actions.copyCommand")}
</>
)}
</Button>
</div>
<div className="p-3 bg-muted/50 rounded-md border border-border">
<code className="text-sm font-mono text-foreground break-all">
<div className="bg-muted/50 border-border rounded-md border p-3">
<code className="text-foreground font-mono text-sm break-all">
echo &quot;{publicKey}&quot; &gt;&gt; ~/.ssh/authorized_keys
</code>
</div>
<p className="text-xs text-muted-foreground">
Copy and paste this command directly into your server terminal to add the key to authorized_keys
<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}>
Close
{t("actions.close")}
</Button>
</div>
</div>

View File

@@ -1,11 +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 { 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 [isResyncing, setIsResyncing] = useState(false);
const [lastSync, setLastSync] = useState<Date | null>(null);
const [syncMessage, setSyncMessage] = useState<string | null>(null);
@@ -15,20 +17,22 @@ export function ResyncButton() {
setIsResyncing(false);
setLastSync(new Date());
if (data.success) {
setSyncMessage(data.message ?? 'Scripts synced successfully');
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 ?? 'Failed to sync scripts');
setSyncMessage(data.error ?? t("messages.failed"));
// Clear message after 3 seconds for errors
setTimeout(() => setSyncMessage(null), 3000);
}
},
onError: (error) => {
setIsResyncing(false);
setSyncMessage(`Error: ${error.message}`);
setSyncMessage(
t("messages.error", { values: { message: error.message } }),
);
setTimeout(() => setSyncMessage(null), 3000);
},
});
@@ -40,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">
Sync scripts with ProxmoxVE repo
<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}
@@ -55,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>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>Sync Json Files</span>
<span>{t("syncJsonFiles")}</span>
</>
)}
</Button>
<ContextualHelpIcon section="sync-button" tooltip="Help with Sync Button" />
<ContextualHelpIcon
section="sync-button"
tooltip={t("helpTooltip")}
/>
</div>
{lastSync && (
<div className="text-xs text-muted-foreground">
Last sync: {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')
? 'bg-error/10 text-error'
: 'bg-success/10 text-success'
}`}>
<div
className={`rounded-lg px-3 py-1 text-sm ${
syncMessage.includes("Error") ||
syncMessage.includes("Failed") ||
syncMessage.includes("Fehler")
? "bg-error/10 text-error"
: "bg-success/10 text-success"
}`}
>
{syncMessage}
</div>
)}

View File

@@ -1,9 +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 { 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;
@@ -12,7 +13,13 @@ interface ScriptCardProps {
onToggleSelect?: (slug: string) => void;
}
export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardProps) {
export function ScriptCard({
script,
onClick,
isSelected = false,
onToggleSelect,
}: ScriptCardProps) {
const { t } = useTranslation("scriptCard");
const [imageError, setImageError] = useState(false);
const handleImageError = () => {
@@ -28,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 ${
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
<div
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"
}`}
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
@@ -61,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 || 'Unnamed Script'}
<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 ? 'Downloaded' : 'Not Downloaded'}
<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>
@@ -99,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 || 'No description available'}
<p className="text-muted-foreground mb-4 line-clamp-3 flex-1 text-sm">
{script.description || t("noDescription")}
</p>
{/* Footer with website link */}
@@ -110,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>Website</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
<span>{t("website")}</span>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</div>

View File

@@ -1,27 +1,29 @@
'use client';
"use client";
import { useState } from 'react';
import { SettingsModal } from './SettingsModal';
import { Button } from './ui/button';
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 [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">
Add and manage PVE Servers:
<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="Add PVE Server"
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"
@@ -40,7 +42,7 @@ export function ServerSettingsButton() {
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Manage PVE Servers
{t("buttonLabel")}
</Button>
</div>

View File

@@ -1,28 +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 { 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 [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">
Application Settings:
<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="Open Settings"
title={t("buttonTitle")}
>
<Settings className="w-5 h-5 mr-2" />
Settings
<Settings className="mr-2 h-5 w-5" />
{t("buttonLabel")}
</Button>
</div>

View File

@@ -1,11 +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 { 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;
@@ -13,10 +14,15 @@ interface SetupModalProps {
}
export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
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);
@@ -28,31 +34,31 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
// Only validate passwords if authentication is enabled
if (enableAuth && password !== confirmPassword) {
setError('Passwords do not match');
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
body: JSON.stringify({
username: enableAuth ? username : undefined,
password: enableAuth ? password : undefined,
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 }),
});
@@ -62,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 {
@@ -70,119 +76,131 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
onComplete();
}
} else {
const errorData = await response.json() as { error: string };
setError(errorData.error ?? 'Failed to setup authentication');
const errorData = (await response.json()) as { error: string };
setError(errorData.error ?? t("errors.setupFailed"));
}
} catch (error) {
console.error('Setup error:', error);
setError('Failed to setup authentication');
console.error("Setup error:", error);
setError(t("errors.setupFailed"));
}
setIsLoading(false);
};
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">Setup Authentication</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">
Set up authentication to secure your application. This will be required for future access.
<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">
Username
<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="Choose a username"
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">
Password
<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="Choose a password"
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">
Confirm Password
<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="Confirm your password"
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">Enable Authentication</h4>
<p className="text-sm text-muted-foreground">
{enableAuth
? 'Authentication will be required on every page load'
: 'Authentication will be optional (can be enabled later in settings)'
}
<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")}
</p>
</div>
<Toggle
checked={enableAuth}
onCheckedChange={setEnableAuth}
disabled={isLoading}
label="Enable authentication"
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>
@@ -191,12 +209,15 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
<Button
type="submit"
disabled={
isLoading ||
(enableAuth && (!username.trim() || !password.trim() || !confirmPassword.trim()))
isLoading ||
(enableAuth &&
(!username.trim() ||
!password.trim() ||
!confirmPassword.trim()))
}
className="w-full"
>
{isLoading ? 'Setting Up...' : 'Complete Setup'}
{isLoading ? t("actions.settingUp") : t("actions.completeSetup")}
</Button>
</form>
</div>

View File

@@ -1,64 +1,66 @@
'use client';
"use client";
import { api } from "~/trpc/react";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { ContextualHelpIcon } from "./ContextualHelpIcon";
import { useTranslation } from "~/lib/i18n/useTranslation";
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
interface VersionDisplayProps {
onOpenReleaseNotes?: () => void;
}
// Loading overlay component with log streaming
function LoadingOverlay({
isNetworkError = false,
logs = []
}: {
isNetworkError?: boolean;
function LoadingOverlay({
isNetworkError = false,
logs = [],
}: {
isNetworkError?: boolean;
logs?: string[];
}) {
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 ? 'Server Restarting' : 'Updating Application'}
<h3 className="text-card-foreground mb-2 text-lg font-semibold">
{isNetworkError
? t("serverRestarting")
: t("updatingApplication")}
</h3>
<p className="text-sm text-muted-foreground">
{isNetworkError
? 'The server is restarting after the update...'
: 'Please stand by while we update your application...'
}
<p className="text-muted-foreground text-sm">
{isNetworkError
? t("serverRestartingMessage")
: t("updatingMessage")}
</p>
<p className="text-xs text-muted-foreground mt-2">
{isNetworkError
? 'This may take a few moments. The page will reload automatically.'
: 'The server will restart automatically when complete.'
}
<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>
))}
@@ -67,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>
@@ -77,25 +85,36 @@ function LoadingOverlay({
);
}
export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) {
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);
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
const lastLogTimeRef = useRef<number>(Date.now());
const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null);
const executeUpdate = api.version.executeUpdate.useMutation({
onSuccess: (result) => {
setUpdateResult({ success: result.success, message: result.message });
if (result.success) {
// Start subscribing to update logs
setShouldSubscribe(true);
setUpdateLogs(['Update started...']);
setUpdateLogs([tOverlay("updateStarted")]);
} else {
setIsUpdating(false);
}
@@ -103,75 +122,38 @@ 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,
});
// Update logs when data changes
useEffect(() => {
if (updateLogsData?.success && updateLogsData.logs) {
lastLogTimeRef.current = Date.now();
setUpdateLogs(updateLogsData.logs);
if (updateLogsData.isComplete) {
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
setIsNetworkError(true);
// Start reconnection attempts when we know update is complete
startReconnectAttempts();
}
}
}, [updateLogsData]);
// Monitor for server connection loss and auto-reload (fallback only)
useEffect(() => {
if (!shouldSubscribe) return;
// Only use this as a fallback - the main trigger should be completion detection
const checkInterval = setInterval(() => {
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
// 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 noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) {
setIsNetworkError(true);
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
// Start trying to reconnect
startReconnectAttempts();
}
}, 10000); // Check every 10 seconds
return () => clearInterval(checkInterval);
}, [shouldSubscribe, isUpdating, updateStartTime, isNetworkError]);
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 = () => {
const startReconnectAttempts = useCallback(() => {
if (reconnectIntervalRef.current) return;
setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']);
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, 'Server is back online! Reloading...']);
setUpdateLogs((prev) => [...prev, tOverlay("serverBackOnline")]);
// Clear interval and reload
if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
}
setTimeout(() => {
window.location.reload();
}, 1000);
@@ -181,7 +163,60 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
}
})();
}, 2000);
};
}, [tOverlay]);
// Update logs when data changes
useEffect(() => {
if (updateLogsData?.success && updateLogsData.logs) {
lastLogTimeRef.current = Date.now();
setUpdateLogs(updateLogsData.logs);
if (updateLogsData.isComplete) {
setUpdateLogs((prev) => [...prev, tOverlay("updateComplete")]);
setIsNetworkError(true);
// Start reconnection attempts when we know update is complete
startReconnectAttempts();
}
}
}, [updateLogsData, tOverlay, startReconnectAttempts]);
// Monitor for server connection loss and auto-reload (fallback only)
useEffect(() => {
if (!shouldSubscribe) return;
// Only use this as a fallback - the main trigger should be completion detection
const checkInterval = setInterval(() => {
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
// 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 noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
if (
hasBeenUpdatingLongEnough &&
noLogsForAWhile &&
isUpdating &&
!isNetworkError
) {
setIsNetworkError(true);
setUpdateLogs((prev) => [...prev, tOverlay("serverRestarting2")]);
// Start trying to reconnect
startReconnectAttempts();
}
}, 10000); // Check every 10 seconds
return () => clearInterval(checkInterval);
}, [
shouldSubscribe,
isUpdating,
updateStartTime,
isNetworkError,
tOverlay,
startReconnectAttempts,
]);
// Cleanup reconnect interval on unmount
useEffect(() => {
@@ -207,7 +242,7 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
return (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="animate-pulse">
Loading...
{t("loading")}
</Badge>
</div>
);
@@ -217,88 +252,104 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
return (
<div className="flex items-center gap-2">
<Badge variant="destructive">
v{versionStatus?.currentVersion ?? 'Unknown'}
v{versionStatus?.currentVersion ?? t("unknownVersion")}
</Badge>
<span className="text-xs text-muted-foreground">
(Unable to check for updates)
<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} />}
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
<Badge
variant={isUpToDate ? "default" : "secondary"}
className={`text-xs ${onOpenReleaseNotes ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
{isUpdating && (
<LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />
)}
<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 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">Updating...</span>
<span className="sm:hidden">...</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">Update Now</span>
<span className="sm:hidden">Update</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="Help with updates" />
<ContextualHelpIcon
section="update-system"
tooltip={t("helpTooltip")}
/>
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">Release Notes:</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" />
</a>
</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>
)}
</div>
)}
{isUpToDate && (
<span className="text-xs text-chart-2">
Up to date
</span>
<span className="text-chart-2 text-xs">{t("upToDate")}</span>
)}
</div>
</>

View File

@@ -1,43 +1,46 @@
'use client';
"use client";
import React from 'react';
import { Button } from './ui/button';
import { Grid3X3, List } from 'lucide-react';
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");
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">Card View</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">List View</span>
<span className="text-sm">{t("listView")}</span>
</Button>
</div>
</div>

View File

@@ -2,7 +2,10 @@ import "~/styles/globals.css";
import { type Metadata, type Viewport } from "next";
import { Geist } from "next/font/google";
import { headers } from "next/headers";
import { LanguageProvider } from "~/lib/i18n/LanguageProvider";
import { defaultLocale, isLocale } from "~/lib/i18n/config";
import { TRPCReactProvider } from "~/trpc/react";
import { AuthProvider } from "./_components/AuthProvider";
import { AuthGuard } from "./_components/AuthGuard";
@@ -11,7 +14,8 @@ import { ModalStackProvider } from "./_components/modal/ModalStackProvider";
export const metadata: Metadata = {
title: "PVE Scripts local",
description: "Manage and execute Proxmox helper scripts locally with live output streaming",
description:
"Manage and execute Proxmox helper scripts locally with live output streaming",
icons: [
{ rel: "icon", url: "/favicon.png", type: "image/png" },
{ rel: "icon", url: "/favicon.ico", sizes: "any" },
@@ -30,26 +34,44 @@ const geist = Geist({
variable: "--font-jetbrains-mono",
});
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const headerList = await headers();
const cookieHeader = headerList.get("cookie");
let initialLocale = defaultLocale;
if (cookieHeader) {
const localeEntry = cookieHeader
.split(";")
.map((entry: string) => entry.trim())
.find((entry: string) => entry.startsWith("pve-locale="));
if (localeEntry) {
const value = localeEntry.split("=")[1];
if (isLocale(value)) {
initialLocale = value;
}
}
}
return (
<html lang="en" className={geist.variable}>
<body
<html lang={initialLocale} className={geist.variable}>
<body
className="bg-background text-foreground transition-colors"
suppressHydrationWarning={true}
>
<ThemeProvider>
<TRPCReactProvider>
<AuthProvider>
<ModalStackProvider>
<AuthGuard>
{children}
</AuthGuard>
</ModalStackProvider>
</AuthProvider>
</TRPCReactProvider>
</ThemeProvider>
<LanguageProvider initialLocale={initialLocale}>
<ThemeProvider>
<TRPCReactProvider>
<AuthProvider>
<ModalStackProvider>
<AuthGuard>{children}</AuthGuard>
</ModalStackProvider>
</AuthProvider>
</TRPCReactProvider>
</ThemeProvider>
</LanguageProvider>
</body>
</html>
);

View File

@@ -1,47 +1,67 @@
"use client";
'use client';
import { useState, useRef, useEffect } from 'react';
import { ScriptsGrid } from './_components/ScriptsGrid';
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
import { ResyncButton } from './_components/ResyncButton';
import { Terminal } from './_components/Terminal';
import { ServerSettingsButton } from './_components/ServerSettingsButton';
import { SettingsButton } from './_components/SettingsButton';
import { HelpButton } from './_components/HelpButton';
import { VersionDisplay } from './_components/VersionDisplay';
import { ThemeToggle } from './_components/ThemeToggle';
import { Button } from './_components/ui/button';
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
import { Footer } from './_components/Footer';
import { Package, HardDrive, FolderOpen } from 'lucide-react';
import { api } from '~/trpc/react';
import { useState, useRef, useEffect } from "react";
import { ScriptsGrid } from "./_components/ScriptsGrid";
import { DownloadedScriptsTab } from "./_components/DownloadedScriptsTab";
import { InstalledScriptsTab } from "./_components/InstalledScriptsTab";
import { ResyncButton } from "./_components/ResyncButton";
import { Terminal } from "./_components/Terminal";
import { ServerSettingsButton } from "./_components/ServerSettingsButton";
import { SettingsButton } from "./_components/SettingsButton";
import { HelpButton } from "./_components/HelpButton";
import { VersionDisplay } from "./_components/VersionDisplay";
import { ThemeToggle } from "./_components/ThemeToggle";
import { LanguageToggle } from "./_components/LanguageToggle";
import { Button } from "./_components/ui/button";
import { ContextualHelpIcon } from "./_components/ContextualHelpIcon";
import {
ReleaseNotesModal,
getLastSeenVersion,
} from "./_components/ReleaseNotesModal";
import { Footer } from "./_components/Footer";
import { Package, HardDrive, FolderOpen } from "lucide-react";
import { useTranslation } from "~/lib/i18n/useTranslation";
import { api } from "~/trpc/react";
export default function Home() {
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => {
if (typeof window !== 'undefined') {
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed';
return savedTab || 'scripts';
const { t } = useTranslation("layout");
const [runningScript, setRunningScript] = useState<{
path: string;
name: string;
mode?: "local" | "ssh";
server?: any;
} | null>(null);
const [activeTab, setActiveTab] = useState<
"scripts" | "downloaded" | "installed"
>(() => {
if (typeof window !== "undefined") {
const savedTab = localStorage.getItem("activeTab") as
| "scripts"
| "downloaded"
| "installed";
return savedTab || "scripts";
}
return 'scripts';
return "scripts";
});
const [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(undefined);
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(
undefined,
);
const terminalRef = useRef<HTMLDivElement>(null);
// Fetch data for script counts
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
const { data: localScriptsData } = api.scripts.getAllDownloadedScripts.useQuery();
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
const { data: scriptCardsData } =
api.scripts.getScriptCardsWithCategories.useQuery();
const { data: localScriptsData } =
api.scripts.getAllDownloadedScripts.useQuery();
const { data: installedScriptsData } =
api.installedScripts.getAllInstalledScripts.useQuery();
const { data: versionData } = api.version.getCurrentVersion.useQuery();
// Save active tab to localStorage whenever it changes
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('activeTab', activeTab);
if (typeof window !== "undefined") {
localStorage.setItem("activeTab", activeTab);
}
}, [activeTab]);
@@ -50,9 +70,12 @@ export default function Home() {
if (versionData?.success && versionData.version) {
const currentVersion = versionData.version;
const lastSeenVersion = getLastSeenVersion();
// If we have a current version and either no last seen version or versions don't match
if (currentVersion && (!lastSeenVersion || currentVersion !== lastSeenVersion)) {
if (
currentVersion &&
(!lastSeenVersion || currentVersion !== lastSeenVersion)
) {
setHighlightVersion(currentVersion);
setReleaseNotesOpen(true);
}
@@ -73,11 +96,11 @@ export default function Home() {
const scriptCounts = {
available: (() => {
if (!scriptCardsData?.success) return 0;
// Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx)
const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach(script => {
scriptCardsData.cards?.forEach((script) => {
if (script?.name && script?.slug) {
// Use slug as unique identifier, only keep first occurrence
if (!scriptMap.has(script.slug)) {
@@ -85,38 +108,40 @@ export default function Home() {
}
}
});
return scriptMap.size;
})(),
downloaded: (() => {
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
// First deduplicate GitHub scripts using Map by slug
const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach(script => {
scriptCardsData.cards?.forEach((script) => {
if (script?.name && script?.slug) {
if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, script);
}
}
});
const deduplicatedGithubScripts = Array.from(scriptMap.values());
const localScripts = localScriptsData.scripts ?? [];
// Count scripts that are both in deduplicated GitHub data and have local versions
return deduplicatedGithubScripts.filter(script => {
return deduplicatedGithubScripts.filter((script) => {
if (!script?.name) return false;
return localScripts.some(local => {
return localScripts.some((local) => {
if (!local?.name) return false;
const localName = local.name.replace(/\.sh$/, '');
return localName.toLowerCase() === script.name.toLowerCase() ||
localName.toLowerCase() === (script.slug ?? '').toLowerCase();
const localName = local.name.replace(/\.sh$/, "");
return (
localName.toLowerCase() === script.name.toLowerCase() ||
localName.toLowerCase() === (script.slug ?? "").toLowerCase()
);
});
}).length;
})(),
installed: installedScriptsData?.scripts?.length ?? 0
installed: installedScriptsData?.scripts?.length ?? 0,
};
const scrollToTerminal = () => {
@@ -124,15 +149,20 @@ export default function Home() {
// Get the element's position and scroll with a small offset for better mobile experience
const elementTop = terminalRef.current.offsetTop;
const offset = window.innerWidth < 768 ? 20 : 0; // Small offset on mobile
window.scrollTo({
top: elementTop - offset,
behavior: 'smooth'
behavior: "smooth",
});
}
};
const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => {
const handleRunScript = (
scriptPath: string,
scriptName: string,
mode?: "local" | "ssh",
server?: any,
) => {
setRunningScript({ path: scriptPath, name: scriptName, mode, server });
// Scroll to terminal after a short delay to ensure it's rendered
setTimeout(scrollToTerminal, 100);
@@ -143,21 +173,22 @@ export default function Home() {
};
return (
<main className="min-h-screen bg-background">
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8">
<main className="bg-background min-h-screen">
<div className="container mx-auto px-2 py-4 sm:px-4 sm:py-8">
{/* Header */}
<div className="text-center mb-6 sm:mb-8">
<div className="flex justify-between items-start mb-2">
<div className="mb-6 text-center sm:mb-8">
<div className="mb-2 flex items-start justify-between">
<div className="flex-1"></div>
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground flex items-center justify-center gap-2 sm:gap-3 flex-1">
<span className="break-words">PVE Scripts Management</span>
<h1 className="text-foreground flex flex-1 items-center justify-center gap-2 text-2xl font-bold sm:gap-3 sm:text-3xl lg:text-4xl">
<span className="break-words">{t("title")}</span>
</h1>
<div className="flex-1 flex justify-end">
<div className="flex flex-1 justify-end gap-2">
<LanguageToggle />
<ThemeToggle />
</div>
</div>
<p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
Manage and execute Proxmox helper scripts locally with live output streaming
<p className="text-muted-foreground mb-4 px-2 text-sm sm:text-base">
{t("tagline")}
</p>
<div className="flex justify-center px-2">
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
@@ -166,7 +197,7 @@ export default function Home() {
{/* Controls */}
<div className="mb-6 sm:mb-8">
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
<div className="bg-card border-border flex flex-col gap-4 rounded-lg border p-4 shadow-sm sm:flex-row sm:flex-wrap sm:items-center sm:p-6">
<ServerSettingsButton />
<SettingsButton />
<ResyncButton />
@@ -176,65 +207,75 @@ export default function Home() {
{/* Tab Navigation */}
<div className="mb-6 sm:mb-8">
<div className="border-b border-border">
<nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-1">
<div className="border-border border-b">
<nav className="-mb-px flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-1">
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('scripts')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'scripts'
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
onClick={() => setActiveTab("scripts")}
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
activeTab === "scripts"
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
}`}
>
<Package className="h-4 w-4" />
<span className="hidden sm:inline">Available Scripts</span>
<span className="sm:hidden">Available</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
<span className="hidden sm:inline">{t("tabs.available")}</span>
<span className="sm:hidden">{t("tabs.availableShort")}</span>
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.available}
</span>
<ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" />
<ContextualHelpIcon
section="available-scripts"
tooltip={t("help.availableTooltip")}
/>
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('downloaded')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'downloaded'
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
onClick={() => setActiveTab("downloaded")}
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
activeTab === "downloaded"
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
}`}
>
<HardDrive className="h-4 w-4" />
<span className="hidden sm:inline">Downloaded Scripts</span>
<span className="sm:hidden">Downloaded</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
<span className="hidden sm:inline">{t("tabs.downloaded")}</span>
<span className="sm:hidden">{t("tabs.downloadedShort")}</span>
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.downloaded}
</span>
<ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" />
<ContextualHelpIcon
section="downloaded-scripts"
tooltip={t("help.downloadedTooltip")}
/>
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('installed')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
activeTab === 'installed'
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
}`}>
onClick={() => setActiveTab("installed")}
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
activeTab === "installed"
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
}`}
>
<FolderOpen className="h-4 w-4" />
<span className="hidden sm:inline">Installed Scripts</span>
<span className="sm:hidden">Installed</span>
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
<span className="hidden sm:inline">{t("tabs.installed")}</span>
<span className="sm:hidden">{t("tabs.installedShort")}</span>
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.installed}
</span>
<ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
<ContextualHelpIcon
section="installed-scripts"
tooltip={t("help.installedTooltip")}
/>
</Button>
</nav>
</div>
</div>
{/* Running Script Terminal */}
{runningScript && (
<div ref={terminalRef} className="mb-8">
@@ -248,17 +289,15 @@ export default function Home() {
)}
{/* Tab Content */}
{activeTab === 'scripts' && (
{activeTab === "scripts" && (
<ScriptsGrid onInstallScript={handleRunScript} />
)}
{activeTab === 'downloaded' && (
{activeTab === "downloaded" && (
<DownloadedScriptsTab onInstallScript={handleRunScript} />
)}
{activeTab === 'installed' && (
<InstalledScriptsTab />
)}
{activeTab === "installed" && <InstalledScriptsTab />}
</div>
{/* Footer */}

View File

@@ -0,0 +1,142 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { defaultLocale, isLocale, locales, type Locale } from "./config";
import { createTranslator, type TranslateOptions } from "./translator";
export interface LanguageContextValue {
locale: Locale;
availableLocales: readonly Locale[];
setLocale: (nextLocale: Locale) => void;
t: (key: string, options?: TranslateOptions) => string;
}
const STORAGE_KEY = "pve-locale";
const COOKIE_KEY = "pve-locale";
export const LanguageContext = createContext<LanguageContextValue | undefined>(
undefined,
);
interface LanguageProviderProps {
children: ReactNode;
initialLocale?: Locale;
}
function getInitialLocale(initialLocale?: Locale): Locale {
if (initialLocale && isLocale(initialLocale)) {
return initialLocale;
}
if (typeof window === "undefined") {
return defaultLocale;
}
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored && isLocale(stored)) {
return stored;
}
const browserLocale = window.navigator.language?.slice(0, 2).toLowerCase();
if (isLocale(browserLocale)) {
return browserLocale;
}
} catch (error) {
console.error("Failed to resolve initial locale", error);
}
return defaultLocale;
}
export function LanguageProvider({
children,
initialLocale,
}: LanguageProviderProps) {
const [locale, setLocaleState] = useState<Locale>(() =>
getInitialLocale(initialLocale),
);
const hasHydrated = useRef(false);
useEffect(() => {
if (typeof document !== "undefined") {
document.documentElement.lang = locale;
}
try {
window.localStorage.setItem(STORAGE_KEY, locale);
document.cookie = `${COOKIE_KEY}=${locale}; path=/; max-age=31536000`;
} catch (error) {
console.error("Failed to persist locale", error);
}
}, [locale]);
useEffect(() => {
if (hasHydrated.current) {
return;
}
hasHydrated.current = true;
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored && isLocale(stored) && stored !== locale) {
setLocaleState(stored);
return;
}
const browserLocale = window.navigator.language
?.slice(0, 2)
.toLowerCase();
if (isLocale(browserLocale) && browserLocale !== locale) {
setLocaleState(browserLocale);
}
} catch (error) {
console.error("Failed to hydrate locale from client settings", error);
}
}, [locale]);
const setLocale = useCallback((nextLocale: Locale) => {
if (!isLocale(nextLocale)) {
return;
}
setLocaleState(nextLocale);
}, []);
const translator = useMemo(() => createTranslator(locale), [locale]);
const value = useMemo<LanguageContextValue>(
() => ({
locale,
availableLocales: locales,
setLocale,
t: (key: string, options?: TranslateOptions) => translator(key, options),
}),
[locale, setLocale, translator],
);
return (
<LanguageContext.Provider value={value}>
{children}
</LanguageContext.Provider>
);
}
export function useLanguageContext(): LanguageContextValue {
const context = useContext(LanguageContext);
if (!context) {
throw new Error(
"useLanguageContext must be used within a LanguageProvider",
);
}
return context;
}

9
src/lib/i18n/config.ts Normal file
View File

@@ -0,0 +1,9 @@
export const locales = ['en', 'de'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'en';
export function isLocale(value: unknown): value is Locale {
return typeof value === 'string' && (locales as readonly string[]).includes(value);
}

443
src/lib/i18n/messages/de.ts Normal file
View File

@@ -0,0 +1,443 @@
import type { NestedMessages } from './types';
export const deMessages: NestedMessages = {
common: {
language: {
english: 'Englisch',
german: 'Deutsch',
switch: 'Sprache wechseln',
},
actions: {
cancel: 'Abbrechen',
close: 'Schließen',
confirm: 'Bestätigen',
save: 'Speichern',
delete: 'Löschen',
edit: 'Bearbeiten',
reset: 'Zurücksetzen',
search: 'Suchen',
retry: 'Erneut versuchen',
install: 'Installieren',
update: 'Aktualisieren',
download: 'Herunterladen',
details: 'Details',
},
status: {
loading: 'Lädt ...',
success: 'Erfolg',
error: 'Ein Fehler ist aufgetreten',
empty: 'Keine Daten verfügbar',
},
},
confirmationModal: {
typeToConfirm: 'Tippe {text} um zu bestätigen:',
placeholder: 'Tippe "{text}" hier ein',
},
errorModal: {
detailsLabel: 'Details:',
errorDetailsLabel: 'Fehlerdetails:',
},
versionDisplay: {
loading: 'Lädt...',
unknownVersion: 'Unbekannt',
unableToCheck: '(Kann nicht nach Updates suchen)',
upToDate: '✓ Aktuell',
releaseNotes: 'Versionshinweise:',
helpTooltip: 'Hilfe zu Updates',
update: {
updateNow: 'Jetzt aktualisieren',
updateNowShort: 'Update',
updating: 'Aktualisiere...',
updatingShort: '...',
},
loadingOverlay: {
serverRestarting: 'Server wird neu gestartet',
updatingApplication: 'Anwendung wird aktualisiert',
serverRestartingMessage: 'Der Server wird nach dem Update neu gestartet...',
updatingMessage: 'Bitte warten Sie, während wir Ihre Anwendung aktualisieren...',
serverRestartingNote: 'Dies kann einige Momente dauern. Die Seite wird automatisch neu geladen.',
updatingNote: 'Der Server wird nach Abschluss automatisch neu gestartet.',
updateStarted: 'Update gestartet...',
updateComplete: 'Update abgeschlossen! Server wird neu gestartet...',
serverRestarting2: 'Server wird neu gestartet... warte auf Wiederverbindung...',
reconnecting: 'Versuche Verbindung wiederherzustellen...',
serverBackOnline: 'Server ist wieder online! Seite wird neu geladen...',
},
},
loadingModal: {
processing: 'Verarbeite',
pleaseWait: 'Bitte warten...',
},
authModal: {
title: 'Authentifizierung erforderlich',
description: 'Bitte geben Sie Ihre Anmeldedaten ein, um auf die Anwendung zuzugreifen.',
username: {
label: 'Benutzername',
placeholder: 'Benutzername eingeben',
},
password: {
label: 'Passwort',
placeholder: 'Passwort eingeben',
},
error: 'Ungültiger Benutzername oder Passwort',
actions: {
signIn: 'Anmelden',
signingIn: 'Anmeldung läuft...',
},
},
setupModal: {
title: 'Authentifizierung einrichten',
description: 'Richten Sie die Authentifizierung ein, um Ihre Anwendung zu sichern. Diese wird für zukünftige Zugriffe erforderlich sein.',
username: {
label: 'Benutzername',
placeholder: 'Wählen Sie einen Benutzernamen',
},
password: {
label: 'Passwort',
placeholder: 'Wählen Sie ein Passwort',
},
confirmPassword: {
label: 'Passwort bestätigen',
placeholder: 'Bestätigen Sie Ihr Passwort',
},
enableAuth: {
title: 'Authentifizierung aktivieren',
descriptionEnabled: 'Authentifizierung wird bei jedem Seitenladevorgang erforderlich sein',
descriptionDisabled: 'Authentifizierung wird optional sein (kann später in den Einstellungen aktiviert werden)',
label: 'Authentifizierung aktivieren',
},
errors: {
passwordMismatch: 'Passwörter stimmen nicht überein',
setupFailed: 'Fehler beim Einrichten der Authentifizierung',
},
actions: {
completeSetup: 'Einrichtung abschließen',
settingUp: 'Wird eingerichtet...',
},
},
helpButton: {
needHelp: 'Brauchen Sie Hilfe?',
openHelp: 'Hilfe öffnen',
help: 'Hilfe',
},
resyncButton: {
syncDescription: 'Skripte mit ProxmoxVE-Repo synchronisieren',
syncing: 'Synchronisiere...',
syncJsonFiles: 'JSON-Dateien synchronisieren',
helpTooltip: 'Hilfe zum Sync-Button',
lastSync: 'Letzte Synchronisierung: {time}',
messages: {
success: 'Skripte erfolgreich synchronisiert',
failed: 'Fehler beim Synchronisieren der Skripte',
error: 'Fehler: {message}',
},
},
viewToggle: {
cardView: 'Karten-Ansicht',
listView: 'Listen-Ansicht',
},
scriptCard: {
unnamedScript: 'Unbenanntes Skript',
downloaded: 'Heruntergeladen',
notDownloaded: 'Nicht heruntergeladen',
noDescription: 'Keine Beschreibung verfügbar',
website: 'Webseite',
},
badge: {
updateable: 'Aktualisierbar',
privileged: 'Privilegiert',
},
serverSettingsButton: {
description: 'PVE-Server hinzufügen und verwalten:',
buttonTitle: 'PVE-Server hinzufügen',
buttonLabel: 'PVE-Server verwalten',
},
settingsButton: {
description: 'Anwendungseinstellungen:',
buttonTitle: 'Einstellungen öffnen',
buttonLabel: 'Einstellungen',
},
executionModeModal: {
title: 'Server auswählen',
loadingServers: 'Lade Server...',
noServersConfigured: 'Keine Server konfiguriert',
addServersHint: 'Fügen Sie Server in den Einstellungen hinzu, um Skripte auszuführen',
openServerSettings: 'Servereinstellungen öffnen',
installConfirmation: {
title: 'Skript-Installation bestätigen',
description: 'Möchten Sie "{scriptName}" auf folgendem Server installieren?',
},
unnamedServer: 'Unbenannter Server',
multipleServers: {
title: 'Server für "{scriptName}" auswählen',
selectServerLabel: 'Server auswählen',
placeholder: 'Wählen Sie einen Server...',
},
actions: {
cancel: 'Abbrechen',
install: 'Installieren',
runOnServer: 'Auf Server ausführen',
},
errors: {
noServerSelected: 'Bitte wählen Sie einen Server für die SSH-Ausführung',
fetchFailed: 'Fehler beim Abrufen der Server',
},
},
publicKeyModal: {
title: 'SSH Public Key',
subtitle: 'Fügen Sie diesen Schlüssel zu den authorized_keys Ihres Servers hinzu',
instructions: {
title: 'Anleitung:',
step1: 'Kopieren Sie den unten stehenden öffentlichen Schlüssel',
step2: 'SSH-Verbindung zu Ihrem Server herstellen:',
step3: 'Schlüssel zu authorized_keys hinzufügen:',
step4: 'Korrekte Berechtigungen setzen:',
},
publicKeyLabel: 'Öffentlicher Schlüssel:',
quickCommandLabel: 'Schnell-Hinzufügen-Befehl:',
quickCommandHint: 'Kopieren Sie diesen Befehl und fügen Sie ihn direkt in Ihr Server-Terminal ein, um den Schlüssel zu authorized_keys hinzuzufügen',
placeholder: 'Öffentlicher Schlüssel wird hier angezeigt...',
actions: {
copy: 'Kopieren',
copied: 'Kopiert!',
copyCommand: 'Befehl kopieren',
close: 'Schließen',
},
copyFallback: 'Bitte kopieren Sie diesen Schlüssel manuell:\n\n',
copyCommandFallback: 'Bitte kopieren Sie diesen Befehl manuell:\n\n',
},
layout: {
title: 'PVE Skriptverwaltung',
tagline: 'Verwalte und starte lokale Proxmox-Hilfsskripte mit Live-Ausgabe',
releaseNotes: 'Versionshinweise',
tabs: {
available: 'Verfügbare Skripte',
availableShort: 'Verfügbar',
downloaded: 'Heruntergeladene Skripte',
downloadedShort: 'Downloads',
installed: 'Installierte Skripte',
installedShort: 'Installiert',
},
help: {
availableTooltip: 'Hilfe zu Verfügbaren Skripten',
downloadedTooltip: 'Hilfe zu heruntergeladenen Skripten',
installedTooltip: 'Hilfe zu installierten Skripten',
},
},
footer: {
copyright: '© {year} PVE Scripts Local',
github: 'GitHub',
releaseNotes: 'Versionshinweise',
},
filterBar: {
loading: 'Gespeicherte Filter werden geladen...',
header: 'Skripte filtern',
helpTooltip: 'Hilfe zum Filtern und Suchen',
search: {
placeholder: 'Skripte durchsuchen...'
},
updatable: {
all: 'Aktualisierbar: Alle',
yes: 'Aktualisierbar: Ja ({count})',
no: 'Aktualisierbar: Nein'
},
types: {
all: 'Alle Typen',
multiple: '{count} Typen',
options: {
ct: 'LXC-Container',
vm: 'Virtuelle Maschine',
addon: 'Add-on',
pve: 'PVE-Host'
}
},
actions: {
clearAllTypes: 'Alle löschen',
clearFilters: 'Alle Filter löschen'
},
sort: {
byName: 'Nach Name',
byCreated: 'Nach Erstelldatum',
oldestFirst: 'Älteste zuerst',
newestFirst: 'Neueste zuerst',
aToZ: 'A-Z',
zToA: 'Z-A'
},
summary: {
showingAll: 'Alle {count} Skripte werden angezeigt',
showingFiltered: '{filtered} von {total} Skripten',
filteredSuffix: '(gefiltert)'
},
persistence: {
enabled: 'Filter werden automatisch gespeichert'
}
},
categorySidebar: {
headerTitle: 'Kategorien',
totalScripts: '{count} Skripte insgesamt',
helpTooltip: 'Hilfe zu Kategorien',
actions: {
collapse: 'Kategorien einklappen',
expand: 'Kategorien ausklappen',
},
all: {
label: 'Alle Kategorien',
tooltip: 'Alle Kategorien ({count})',
},
tooltips: {
category: '{category} ({count})',
},
categories: {
'Proxmox & Virtualization': 'Proxmox & Virtualisierung',
'Operating Systems': 'Betriebssysteme',
'Containers & Docker': 'Container & Docker',
'Network & Firewall': 'Netzwerk & Firewall',
'Adblock & DNS': 'Adblock & DNS',
'Authentication & Security': 'Authentifizierung & Sicherheit',
'Backup & Recovery': 'Backup & Wiederherstellung',
'Databases': 'Datenbanken',
'Monitoring & Analytics': 'Monitoring & Analysen',
'Dashboards & Frontends': 'Dashboards & Frontends',
'Files & Downloads': 'Dateien & Downloads',
'Documents & Notes': 'Dokumente & Notizen',
'Media & Streaming': 'Medien & Streaming',
'*Arr Suite': '*Arr Suite',
'NVR & Cameras': 'NVR & Kameras',
'IoT & Smart Home': 'IoT & Smart Home',
'ZigBee, Z-Wave & Matter': 'ZigBee, Z-Wave & Matter',
'MQTT & Messaging': 'MQTT & Messaging',
'Automation & Scheduling': 'Automatisierung & Planung',
'AI / Coding & Dev-Tools': 'KI / Coding & Dev-Tools',
'Webservers & Proxies': 'Webserver & Proxys',
'Bots & ChatOps': 'Bots & ChatOps',
'Finance & Budgeting': 'Finanzen & Budgetierung',
'Gaming & Leisure': 'Gaming & Freizeit',
'Business & ERP': 'Business & ERP',
'Miscellaneous': 'Verschiedenes',
},
},
settings: {
title: 'Einstellungen',
close: 'Schließen',
help: 'Hilfe zu den Einstellungen',
tabs: {
general: 'Allgemein',
github: 'GitHub',
auth: 'Authentifizierung',
},
general: {
title: 'Allgemeine Einstellungen',
description: 'Konfiguriere allgemeine Anwendungspräferenzen und Verhalten.',
sections: {
theme: {
title: 'Design',
description: 'Wähle dein bevorzugtes Farbdesign für die Anwendung.',
current: 'Aktuelles Design',
lightLabel: 'Hell',
darkLabel: 'Dunkel',
actions: {
light: 'Hell',
dark: 'Dunkel',
},
},
language: {
title: 'Sprache',
description: 'Wähle deine bevorzugte Anzeigesprache.',
current: 'Aktuelle Sprache',
actions: {
english: 'Englisch',
german: 'Deutsch',
},
},
filters: {
title: 'Filter speichern',
description: 'Speichere deine konfigurierten Skriptfilter.',
toggleLabel: 'Filterspeicherung aktivieren',
savedTitle: 'Gespeicherte Filter',
savedActive: 'Filter sind derzeit gespeichert',
savedEmpty: 'Noch keine Filter gespeichert',
details: {
search: 'Suche: {value}',
types: 'Typen: {count} ausgewählt',
sort: 'Sortierung: {field} ({order})',
none: 'Keine',
},
actions: {
clear: 'Löschen',
},
},
colorCoding: {
title: 'Server-Farbcodierung',
description: 'Aktiviere die Farbcodierung für Server, um sie in der Anwendung visuell zu unterscheiden.',
toggleLabel: 'Server-Farbcodierung aktivieren',
},
},
},
github: {
title: 'GitHub-Integration',
description: 'Konfiguriere die GitHub-Integration für Skriptverwaltung und Updates.',
sections: {
token: {
title: 'Persönliches GitHub-Zugriffstoken',
description: 'Speichere ein GitHub Personal Access Token, um GitHub API-Ratenbeschränkungen zu umgehen.',
tokenLabel: 'Token',
placeholder: 'Gib dein GitHub Personal Access Token ein',
actions: {
save: 'Token speichern',
saving: 'Speichern...',
refresh: 'Aktualisieren',
loading: 'Lädt...',
},
},
},
},
auth: {
title: 'Authentifizierungseinstellungen',
description: 'Konfiguriere die Authentifizierung, um den Zugriff auf deine Anwendung zu sichern.',
sections: {
status: {
title: 'Authentifizierungsstatus',
enabledWithCredentials: 'Authentifizierung ist {status}. Aktueller Benutzername: {username}',
enabledWithoutCredentials: 'Authentifizierung ist {status}. Keine Anmeldedaten konfiguriert.',
notSetup: 'Authentifizierung wurde noch nicht eingerichtet.',
enabled: 'aktiviert',
disabled: 'deaktiviert',
toggleLabel: 'Authentifizierung aktivieren',
toggleEnabled: 'Authentifizierung ist bei jedem Seitenladen erforderlich',
toggleDisabled: 'Authentifizierung ist optional',
},
credentials: {
title: 'Anmeldedaten aktualisieren',
description: 'Ändere deinen Benutzernamen und dein Passwort für die Authentifizierung.',
usernameLabel: 'Benutzername',
usernamePlaceholder: 'Benutzername eingeben',
passwordLabel: 'Neues Passwort',
passwordPlaceholder: 'Neues Passwort eingeben',
confirmPasswordLabel: 'Passwort bestätigen',
confirmPasswordPlaceholder: 'Neues Passwort bestätigen',
actions: {
update: 'Anmeldedaten aktualisieren',
updating: 'Speichern...',
refresh: 'Aktualisieren',
loading: 'Lädt...',
},
},
},
},
messages: {
filterSettingSaved: 'Filterspeicherungseinstellung aktualisiert!',
filterSettingError: 'Fehler beim Speichern der Einstellung',
clearFiltersSuccess: 'Gespeicherte Filter gelöscht!',
clearFiltersError: 'Fehler beim Löschen der Filter',
colorCodingSuccess: 'Farbcodierungseinstellung erfolgreich gespeichert',
colorCodingError: 'Fehler beim Speichern der Farbcodierungseinstellung',
githubTokenSuccess: 'GitHub-Token erfolgreich gespeichert!',
githubTokenError: 'Fehler beim Speichern des Tokens',
authCredentialsSuccess: 'Authentifizierungsanmeldedaten erfolgreich aktualisiert!',
authCredentialsError: 'Fehler beim Speichern der Anmeldedaten',
authStatusSuccess: 'Authentifizierung erfolgreich {status}!',
authStatusError: 'Fehler beim Aktualisieren des Authentifizierungsstatus',
passwordMismatch: 'Passwörter stimmen nicht überein',
},
},
};

443
src/lib/i18n/messages/en.ts Normal file
View File

@@ -0,0 +1,443 @@
import type { NestedMessages } from './types';
export const enMessages: NestedMessages = {
common: {
language: {
english: 'English',
german: 'German',
switch: 'Switch language',
},
actions: {
cancel: 'Cancel',
close: 'Close',
confirm: 'Confirm',
save: 'Save',
delete: 'Delete',
edit: 'Edit',
reset: 'Reset',
search: 'Search',
retry: 'Retry',
install: 'Install',
update: 'Update',
download: 'Download',
details: 'Details',
},
status: {
loading: 'Loading...',
success: 'Success',
error: 'An error occurred',
empty: 'No data available',
},
},
confirmationModal: {
typeToConfirm: 'Type {text} to confirm:',
placeholder: 'Type "{text}" here',
},
errorModal: {
detailsLabel: 'Details:',
errorDetailsLabel: 'Error Details:',
},
versionDisplay: {
loading: 'Loading...',
unknownVersion: 'Unknown',
unableToCheck: '(Unable to check for updates)',
upToDate: '✓ Up to date',
releaseNotes: 'Release Notes:',
helpTooltip: 'Help with updates',
update: {
updateNow: 'Update Now',
updateNowShort: 'Update',
updating: 'Updating...',
updatingShort: '...',
},
loadingOverlay: {
serverRestarting: 'Server Restarting',
updatingApplication: 'Updating Application',
serverRestartingMessage: 'The server is restarting after the update...',
updatingMessage: 'Please stand by while we update your application...',
serverRestartingNote: 'This may take a few moments. The page will reload automatically.',
updatingNote: 'The server will restart automatically when complete.',
updateStarted: 'Update started...',
updateComplete: 'Update complete! Server restarting...',
serverRestarting2: 'Server restarting... waiting for reconnection...',
reconnecting: 'Attempting to reconnect...',
serverBackOnline: 'Server is back online! Reloading...',
},
},
layout: {
title: 'PVE Scripts Management',
tagline: 'Manage and execute Proxmox helper scripts locally with live output streaming',
releaseNotes: 'Release Notes',
tabs: {
available: 'Available Scripts',
availableShort: 'Available',
downloaded: 'Downloaded Scripts',
downloadedShort: 'Downloaded',
installed: 'Installed Scripts',
installedShort: 'Installed',
},
help: {
availableTooltip: 'Help with Available Scripts',
downloadedTooltip: 'Help with Downloaded Scripts',
installedTooltip: 'Help with Installed Scripts',
},
},
footer: {
copyright: '© {year} PVE Scripts Local',
github: 'GitHub',
releaseNotes: 'Release Notes',
},
filterBar: {
loading: 'Loading saved filters...',
header: 'Filter Scripts',
helpTooltip: 'Help with filtering and searching',
search: {
placeholder: 'Search scripts...'
},
updatable: {
all: 'Updatable: All',
yes: 'Updatable: Yes ({count})',
no: 'Updatable: No'
},
types: {
all: 'All Types',
multiple: '{count} Types',
options: {
ct: 'LXC Container',
vm: 'Virtual Machine',
addon: 'Add-on',
pve: 'PVE Host'
}
},
actions: {
clearAllTypes: 'Clear all',
clearFilters: 'Clear all filters'
},
sort: {
byName: 'By Name',
byCreated: 'By Created Date',
oldestFirst: 'Oldest First',
newestFirst: 'Newest First',
aToZ: 'A-Z',
zToA: 'Z-A'
},
summary: {
showingAll: 'Showing all {count} scripts',
showingFiltered: '{filtered} of {total} scripts',
filteredSuffix: '(filtered)'
},
persistence: {
enabled: 'Filters are being saved automatically'
}
},
categorySidebar: {
headerTitle: 'Categories',
totalScripts: '{count} total scripts',
helpTooltip: 'Help with categories',
actions: {
collapse: 'Collapse categories',
expand: 'Expand categories',
},
all: {
label: 'All Categories',
tooltip: 'All Categories ({count})',
},
tooltips: {
category: '{category} ({count})',
},
categories: {
'Proxmox & Virtualization': 'Proxmox & Virtualization',
'Operating Systems': 'Operating Systems',
'Containers & Docker': 'Containers & Docker',
'Network & Firewall': 'Network & Firewall',
'Adblock & DNS': 'Adblock & DNS',
'Authentication & Security': 'Authentication & Security',
'Backup & Recovery': 'Backup & Recovery',
'Databases': 'Databases',
'Monitoring & Analytics': 'Monitoring & Analytics',
'Dashboards & Frontends': 'Dashboards & Frontends',
'Files & Downloads': 'Files & Downloads',
'Documents & Notes': 'Documents & Notes',
'Media & Streaming': 'Media & Streaming',
'*Arr Suite': '*Arr Suite',
'NVR & Cameras': 'NVR & Cameras',
'IoT & Smart Home': 'IoT & Smart Home',
'ZigBee, Z-Wave & Matter': 'ZigBee, Z-Wave & Matter',
'MQTT & Messaging': 'MQTT & Messaging',
'Automation & Scheduling': 'Automation & Scheduling',
'AI / Coding & Dev-Tools': 'AI / Coding & Dev-Tools',
'Webservers & Proxies': 'Webservers & Proxies',
'Bots & ChatOps': 'Bots & ChatOps',
'Finance & Budgeting': 'Finance & Budgeting',
'Gaming & Leisure': 'Gaming & Leisure',
'Business & ERP': 'Business & ERP',
'Miscellaneous': 'Miscellaneous',
},
},
settings: {
title: 'Settings',
close: 'Close',
help: 'Help with General Settings',
tabs: {
general: 'General',
github: 'GitHub',
auth: 'Authentication',
},
general: {
title: 'General Settings',
description: 'Configure general application preferences and behavior.',
sections: {
theme: {
title: 'Theme',
description: 'Choose your preferred color theme for the application.',
current: 'Current Theme',
lightLabel: 'Light mode',
darkLabel: 'Dark mode',
actions: {
light: 'Light',
dark: 'Dark',
},
},
language: {
title: 'Language',
description: 'Choose your preferred display language.',
current: 'Current Language',
actions: {
english: 'English',
german: 'German',
},
},
filters: {
title: 'Save Filters',
description: 'Save your configured script filters.',
toggleLabel: 'Enable filter saving',
savedTitle: 'Saved Filters',
savedActive: 'Filters are currently saved',
savedEmpty: 'No filters saved yet',
details: {
search: 'Search: {value}',
types: 'Types: {count} selected',
sort: 'Sort: {field} ({order})',
none: 'None',
},
actions: {
clear: 'Clear',
},
},
colorCoding: {
title: 'Server Color Coding',
description: 'Enable color coding for servers to visually distinguish them throughout the application.',
toggleLabel: 'Enable server color coding',
},
},
},
github: {
title: 'GitHub Integration',
description: 'Configure GitHub integration for script management and updates.',
sections: {
token: {
title: 'GitHub Personal Access Token',
description: 'Save a GitHub Personal Access Token to circumvent GitHub API rate limits.',
tokenLabel: 'Token',
placeholder: 'Enter your GitHub Personal Access Token',
actions: {
save: 'Save Token',
saving: 'Saving...',
refresh: 'Refresh',
loading: 'Loading...',
},
},
},
},
auth: {
title: 'Authentication Settings',
description: 'Configure authentication to secure access to your application.',
sections: {
status: {
title: 'Authentication Status',
enabledWithCredentials: 'Authentication is {status}. Current username: {username}',
enabledWithoutCredentials: 'Authentication is {status}. No credentials configured.',
notSetup: 'Authentication setup has not been completed yet.',
enabled: 'enabled',
disabled: 'disabled',
toggleLabel: 'Enable Authentication',
toggleEnabled: 'Authentication is required on every page load',
toggleDisabled: 'Authentication is optional',
},
credentials: {
title: 'Update Credentials',
description: 'Change your username and password for authentication.',
usernameLabel: 'Username',
usernamePlaceholder: 'Enter username',
passwordLabel: 'New Password',
passwordPlaceholder: 'Enter new password',
confirmPasswordLabel: 'Confirm Password',
confirmPasswordPlaceholder: 'Confirm new password',
actions: {
update: 'Update Credentials',
updating: 'Saving...',
refresh: 'Refresh',
loading: 'Loading...',
},
},
},
},
messages: {
filterSettingSaved: 'Save filter setting updated!',
filterSettingError: 'Failed to save setting',
clearFiltersSuccess: 'Saved filters cleared!',
clearFiltersError: 'Failed to clear filters',
colorCodingSuccess: 'Color coding setting saved successfully',
colorCodingError: 'Failed to save color coding setting',
githubTokenSuccess: 'GitHub token saved successfully!',
githubTokenError: 'Failed to save token',
authCredentialsSuccess: 'Authentication credentials updated successfully!',
authCredentialsError: 'Failed to save credentials',
authStatusSuccess: 'Authentication {status} successfully!',
authStatusError: 'Failed to update auth status',
passwordMismatch: 'Passwords do not match',
},
},
loadingModal: {
processing: 'Processing',
pleaseWait: 'Please wait...',
},
authModal: {
title: 'Authentication Required',
description: 'Please enter your credentials to access the application.',
username: {
label: 'Username',
placeholder: 'Enter your username',
},
password: {
label: 'Password',
placeholder: 'Enter your password',
},
error: 'Invalid username or password',
actions: {
signIn: 'Sign In',
signingIn: 'Signing In...',
},
},
setupModal: {
title: 'Setup Authentication',
description: 'Set up authentication to secure your application. This will be required for future access.',
username: {
label: 'Username',
placeholder: 'Choose a username',
},
password: {
label: 'Password',
placeholder: 'Choose a password',
},
confirmPassword: {
label: 'Confirm Password',
placeholder: 'Confirm your password',
},
enableAuth: {
title: 'Enable Authentication',
descriptionEnabled: 'Authentication will be required on every page load',
descriptionDisabled: 'Authentication will be optional (can be enabled later in settings)',
label: 'Enable authentication',
},
errors: {
passwordMismatch: 'Passwords do not match',
setupFailed: 'Failed to setup authentication',
},
actions: {
completeSetup: 'Complete Setup',
settingUp: 'Setting Up...',
},
},
helpButton: {
needHelp: 'Need help?',
openHelp: 'Open Help',
help: 'Help',
},
resyncButton: {
syncDescription: 'Sync scripts with ProxmoxVE repo',
syncing: 'Syncing...',
syncJsonFiles: 'Sync Json Files',
helpTooltip: 'Help with Sync Button',
lastSync: 'Last sync: {time}',
messages: {
success: 'Scripts synced successfully',
failed: 'Failed to sync scripts',
error: 'Error: {message}',
},
},
viewToggle: {
cardView: 'Card View',
listView: 'List View',
},
scriptCard: {
unnamedScript: 'Unnamed Script',
downloaded: 'Downloaded',
notDownloaded: 'Not Downloaded',
noDescription: 'No description available',
website: 'Website',
},
badge: {
updateable: 'Updateable',
privileged: 'Privileged',
},
serverSettingsButton: {
description: 'Add and manage PVE Servers:',
buttonTitle: 'Add PVE Server',
buttonLabel: 'Manage PVE Servers',
},
settingsButton: {
description: 'Application Settings:',
buttonTitle: 'Open Settings',
buttonLabel: 'Settings',
},
executionModeModal: {
title: 'Select Server',
loadingServers: 'Loading servers...',
noServersConfigured: 'No servers configured',
addServersHint: 'Add servers in Settings to execute scripts',
openServerSettings: 'Open Server Settings',
installConfirmation: {
title: 'Install Script Confirmation',
description: 'Do you want to install "{scriptName}" on the following server?',
},
unnamedServer: 'Unnamed Server',
multipleServers: {
title: 'Select server to execute "{scriptName}"',
selectServerLabel: 'Select Server',
placeholder: 'Select a server...',
},
actions: {
cancel: 'Cancel',
install: 'Install',
runOnServer: 'Run on Server',
},
errors: {
noServerSelected: 'Please select a server for SSH execution',
fetchFailed: 'Failed to fetch servers',
},
},
publicKeyModal: {
title: 'SSH Public Key',
subtitle: 'Add this key to your server\'s authorized_keys',
instructions: {
title: 'Instructions:',
step1: 'Copy the public key below',
step2: 'SSH into your server:',
step3: 'Add the key to authorized_keys:',
step4: 'Set proper permissions:',
},
publicKeyLabel: 'Public Key:',
quickCommandLabel: 'Quick Add Command:',
quickCommandHint: 'Copy and paste this command directly into your server terminal to add the key to authorized_keys',
placeholder: 'Public key will appear here...',
actions: {
copy: 'Copy',
copied: 'Copied!',
copyCommand: 'Copy Command',
close: 'Close',
},
copyFallback: 'Please manually copy this key:\n\n',
copyCommandFallback: 'Please manually copy this command:\n\n',
},
};

View File

@@ -0,0 +1,9 @@
import type { Locale } from '../config';
import type { NestedMessages } from './types';
import { enMessages } from './en';
import { deMessages } from './de';
export const messages: Record<Locale, NestedMessages> = {
en: enMessages,
de: deMessages,
};

View File

@@ -0,0 +1,3 @@
export type NestedMessages = {
[key: string]: string | NestedMessages;
};

View File

@@ -0,0 +1,76 @@
import { defaultLocale, type Locale, isLocale } from './config';
import { messages } from './messages';
import type { NestedMessages } from './messages/types';
export type TranslateValues = Record<string, string | number>;
export interface TranslateOptions {
fallback?: string;
values?: TranslateValues;
}
function getNestedMessage(tree: NestedMessages | string | undefined, segments: string[]): string | undefined {
if (segments.length === 0) {
return typeof tree === 'string' ? tree : undefined;
}
if (!tree || typeof tree === 'string') {
return undefined;
}
const [current, ...rest] = segments;
if (!current) {
return undefined;
}
const next: NestedMessages | string | undefined = tree[current];
return getNestedMessage(next, rest);
}
function formatMessage(template: string, values?: TranslateValues): string {
if (!values) {
return template;
}
return template.replace(/\{(.*?)\}/g, (match, token: string) => {
const value = values[token];
if (value === undefined || value === null) {
return match;
}
return String(value);
});
}
function resolveMessage(locale: Locale, key: string): string | undefined {
const dictionary = messages[locale];
if (!dictionary) {
return undefined;
}
const segments = key.split('.').filter(Boolean);
return getNestedMessage(dictionary, segments);
}
export function createTranslator(locale: Locale) {
const normalizedLocale: Locale = isLocale(locale) ? locale : defaultLocale;
return (key: string, options?: TranslateOptions): string => {
const fallbackLocales: Locale[] = [normalizedLocale];
if (normalizedLocale !== defaultLocale) {
fallbackLocales.push(defaultLocale);
}
for (const currentLocale of fallbackLocales) {
const message = resolveMessage(currentLocale, key);
if (typeof message === 'string') {
return formatMessage(message, options?.values);
}
}
if (options?.fallback) {
return formatMessage(options.fallback, options.values);
}
return key;
};
}

View File

@@ -0,0 +1,31 @@
'use client';
import { useCallback } from 'react';
import { type LanguageContextValue, useLanguageContext } from './LanguageProvider';
import type { TranslateOptions } from './translator';
export interface UseTranslationResult {
locale: LanguageContextValue['locale'];
availableLocales: LanguageContextValue['availableLocales'];
setLocale: LanguageContextValue['setLocale'];
t: (key: string, options?: TranslateOptions) => string;
}
export function useTranslation(namespace?: string): UseTranslationResult {
const { t: translate, locale, setLocale, availableLocales } = useLanguageContext();
const scopedTranslate = useCallback(
(key: string, options?: TranslateOptions) => {
const namespacedKey = namespace ? `${namespace}.${key}` : key;
return translate(namespacedKey, options);
},
[namespace, translate],
);
return {
locale,
availableLocales,
setLocale,
t: scopedTranslate,
};
}