Compare commits
8 Commits
feat/use_n
...
localizati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e1975dd1d | ||
|
|
fc6e13946d | ||
|
|
f16f0e58cd | ||
|
|
dd737a8bc7 | ||
|
|
8fb9936cd6 | ||
|
|
e0d5a07d18 | ||
|
|
946038a29d | ||
|
|
e994f14d0a |
34
package-lock.json
generated
34
package-lock.json
generated
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,4 +90,4 @@
|
||||
"overrides": {
|
||||
"prismjs": "^1.30.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "{scriptName}" 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 "{scriptName}"
|
||||
<h3 className="text-foreground mb-2 text-lg font-medium">
|
||||
{t("multipleServers.title", { values: { scriptName } })}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Server Selection */}
|
||||
<div className="mb-6">
|
||||
<label htmlFor="server" className="block text-sm font-medium text-foreground mb-2">
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
765
src/app/_components/GeneralSettingsModal.tsx.backup
Normal file
765
src/app/_components/GeneralSettingsModal.tsx.backup
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
50
src/app/_components/LanguageToggle.tsx
Normal file
50
src/app/_components/LanguageToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'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 "<paste-key>" >> ~/.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 "<paste-key>" >>
|
||||
~/.ssh/authorized_keys
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
{t("instructions.step4")}{" "}
|
||||
<code className="bg-muted rounded px-1">
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
</code>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Public Key */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">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 "{publicKey}" >> ~/.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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
243
src/app/page.tsx
243
src/app/page.tsx
@@ -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 */}
|
||||
|
||||
142
src/lib/i18n/LanguageProvider.tsx
Normal file
142
src/lib/i18n/LanguageProvider.tsx
Normal 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
9
src/lib/i18n/config.ts
Normal 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
443
src/lib/i18n/messages/de.ts
Normal 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
443
src/lib/i18n/messages/en.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
9
src/lib/i18n/messages/index.ts
Normal file
9
src/lib/i18n/messages/index.ts
Normal 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,
|
||||
};
|
||||
3
src/lib/i18n/messages/types.ts
Normal file
3
src/lib/i18n/messages/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type NestedMessages = {
|
||||
[key: string]: string | NestedMessages;
|
||||
};
|
||||
76
src/lib/i18n/translator.ts
Normal file
76
src/lib/i18n/translator.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
31
src/lib/i18n/useTranslation.ts
Normal file
31
src/lib/i18n/useTranslation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user