Add i18n support and language toggle components

Introduces internationalization (i18n) support with new translation files, a LanguageProvider, and useTranslation hook. Refactors CategorySidebar to use translations for labels and tooltips, and adds a LanguageToggle component. Updates related UI components to support localization.
This commit is contained in:
CanbiZ
2025-10-20 17:05:33 +02:00
parent 56a8b0dac9
commit e994f14d0a
18 changed files with 2777 additions and 625 deletions

34
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,34 +1,48 @@
'use client';
"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 { 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 });
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 [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);
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 [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);
@@ -48,13 +62,13 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
const loadGithubToken = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/settings/github-token');
const response = await fetch("/api/settings/github-token");
if (response.ok) {
const data = await response.json();
setGithubToken((data.token as string) ?? '');
setGithubToken((data.token as string) ?? "");
}
} catch (error) {
console.error('Error loading GitHub token:', error);
console.error("Error loading GitHub token:", error);
} finally {
setIsLoading(false);
}
@@ -62,94 +76,106 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
const loadSaveFilter = async () => {
try {
const response = await fetch('/api/settings/save-filter');
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);
console.error("Error loading save filter setting:", error);
}
};
const saveSaveFilter = async (enabled: boolean) => {
try {
const response = await fetch('/api/settings/save-filter', {
method: 'POST',
const response = await fetch("/api/settings/save-filter", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({ enabled }),
});
if (response.ok) {
setSaveFilter(enabled);
setMessage({ type: 'success', text: 'Save filter setting updated!' });
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' });
setMessage({
type: "error",
text: errorData.error ?? "Failed to save setting",
});
}
} catch {
setMessage({ type: 'error', text: 'Failed to save setting' });
setMessage({ type: "error", text: "Failed to save setting" });
}
};
const loadSavedFilters = async () => {
try {
const response = await fetch('/api/settings/filters');
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);
console.error("Error loading saved filters:", error);
}
};
const clearSavedFilters = async () => {
try {
const response = await fetch('/api/settings/filters', {
method: 'DELETE',
const response = await fetch("/api/settings/filters", {
method: "DELETE",
});
if (response.ok) {
setSavedFilters(null);
setMessage({ type: 'success', text: 'Saved filters cleared!' });
setMessage({ type: "success", text: "Saved filters cleared!" });
} else {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to clear filters' });
setMessage({
type: "error",
text: errorData.error ?? "Failed to clear filters",
});
}
} catch {
setMessage({ type: 'error', text: 'Failed to clear filters' });
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',
const response = await fetch("/api/settings/github-token", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({ token: githubToken }),
});
if (response.ok) {
setMessage({ type: 'success', text: 'GitHub token saved successfully!' });
setMessage({
type: "success",
text: "GitHub token saved successfully!",
});
} else {
const errorData = await response.json();
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save token' });
setMessage({
type: "error",
text: errorData.error ?? "Failed to save token",
});
}
} catch {
setMessage({ type: 'error', text: 'Failed to save token' });
setMessage({ type: "error", text: "Failed to save token" });
} finally {
setIsSaving(false);
}
@@ -157,37 +183,46 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
const loadColorCodingSetting = async () => {
try {
const response = await fetch('/api/settings/color-coding');
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);
console.error("Error loading color coding setting:", error);
}
};
const saveColorCodingSetting = async (enabled: boolean) => {
try {
const response = await fetch('/api/settings/color-coding', {
method: 'POST',
const response = await fetch("/api/settings/color-coding", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({ enabled }),
});
if (response.ok) {
setColorCodingEnabled(enabled);
setMessage({ type: 'success', text: 'Color coding setting saved successfully' });
setMessage({
type: "success",
text: "Color coding setting saved successfully",
});
setTimeout(() => setMessage(null), 3000);
} else {
setMessage({ type: 'error', text: 'Failed to save color coding setting' });
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' });
console.error("Error saving color coding setting:", error);
setMessage({
type: "error",
text: "Failed to save color coding setting",
});
setTimeout(() => setMessage(null), 3000);
}
};
@@ -195,16 +230,21 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
const loadAuthCredentials = async () => {
setAuthLoading(true);
try {
const response = await fetch('/api/settings/auth-credentials');
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 ?? '');
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);
console.error("Error loading auth credentials:", error);
} finally {
setAuthLoading(false);
}
@@ -212,37 +252,43 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
const saveAuthCredentials = async () => {
if (authPassword !== authConfirmPassword) {
setMessage({ type: 'error', text: 'Passwords do not match' });
setMessage({ type: "error", text: "Passwords do not match" });
return;
}
setAuthLoading(true);
setMessage(null);
try {
const response = await fetch('/api/settings/auth-credentials', {
method: 'POST',
const response = await fetch("/api/settings/auth-credentials", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({
username: authUsername,
body: JSON.stringify({
username: authUsername,
password: authPassword,
enabled: authEnabled
enabled: authEnabled,
}),
});
if (response.ok) {
setMessage({ type: 'success', text: 'Authentication credentials updated successfully!' });
setAuthPassword('');
setAuthConfirmPassword('');
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' });
setMessage({
type: "error",
text: errorData.error ?? "Failed to save credentials",
});
}
} catch {
setMessage({ type: 'error', text: 'Failed to save credentials' });
setMessage({ type: "error", text: "Failed to save credentials" });
} finally {
setAuthLoading(false);
}
@@ -251,28 +297,31 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
const toggleAuthEnabled = async (enabled: boolean) => {
setAuthLoading(true);
setMessage(null);
try {
const response = await fetch('/api/settings/auth-credentials', {
method: 'PATCH',
const response = await fetch("/api/settings/auth-credentials", {
method: "PATCH",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({ enabled }),
});
if (response.ok) {
setAuthEnabled(enabled);
setMessage({
type: 'success',
text: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully!`
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' });
setMessage({
type: "error",
text: errorData.error ?? "Failed to update auth status",
});
}
} catch {
setMessage({ type: 'error', text: 'Failed to update auth status' });
setMessage({ type: "error", text: "Failed to update auth status" });
} finally {
setAuthLoading(false);
}
@@ -281,13 +330,18 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
if (!isOpen) return null;
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-2 sm:p-4">
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden">
<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="flex items-center justify-between p-4 sm:p-6 border-b border-border">
<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-xl sm:text-2xl font-bold text-card-foreground">Settings</h2>
<ContextualHelpIcon section="general-settings" tooltip="Help with General Settings" />
<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}
@@ -295,47 +349,57 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg className="w-5 h-5 sm:w-6 sm: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-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-b border-border">
<nav className="flex flex-col sm:flex-row space-y-1 sm:space-y-0 sm:space-x-8 px-4 sm:px-6">
<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')}
onClick={() => setActiveTab("general")}
variant="ghost"
size="null"
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
activeTab === 'general'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
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')}
onClick={() => setActiveTab("github")}
variant="ghost"
size="null"
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
activeTab === 'github'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
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')}
onClick={() => setActiveTab("auth")}
variant="ghost"
size="null"
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
activeTab === 'auth'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
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
@@ -344,112 +408,149 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
</div>
{/* Content */}
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
{activeTab === 'general' && (
<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-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">General Settings</h3>
<p className="text-sm sm:text-base text-muted-foreground mb-4">
Configure general application preferences and behavior.
</p>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Theme</h4>
<p className="text-sm text-muted-foreground mb-4">Choose your preferred color theme for the application.</p>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">Current Theme</p>
<p className="text-xs text-muted-foreground">
{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>
<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="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Save Filters</h4>
<p className="text-sm text-muted-foreground mb-4">Save your configured script filters.</p>
<Toggle
checked={saveFilter}
onCheckedChange={saveSaveFilter}
label="Enable filter saving"
/>
{saveFilter && (
<div className="mt-4 p-3 bg-muted rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">Saved Filters</p>
<p className="text-xs text-muted-foreground">
{savedFilters ? 'Filters are currently saved' : 'No filters saved yet'}
</p>
{savedFilters && (
<div className="mt-2 text-xs text-muted-foreground">
<div>Search: {savedFilters.searchQuery ?? 'None'}</div>
<div>Types: {savedFilters.selectedTypes?.length ?? 0} selected</div>
<div>Sort: {savedFilters.sortBy} ({savedFilters.sortOrder})</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 && (
<Button
onClick={clearSavedFilters}
variant="outline"
size="sm"
className="text-error hover:text-error/80"
>
Clear
</Button>
<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>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Server Color Coding</h4>
<p className="text-sm text-muted-foreground mb-4">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 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' && (
{activeTab === "github" && (
<div className="space-y-4 sm:space-y-6">
<div>
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">GitHub Integration</h3>
<p className="text-sm sm:text-base text-muted-foreground mb-4">
Configure GitHub integration for script management and updates.
<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="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">GitHub Personal Access Token</h4>
<p className="text-sm text-muted-foreground mb-4">Save a GitHub Personal Access Token to circumvent GitHub API rate limits.</p>
<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="block text-sm font-medium text-foreground mb-1">
<label
htmlFor="github-token"
className="text-foreground mb-1 block text-sm font-medium"
>
Token
</label>
<Input
@@ -457,36 +558,42 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
type="password"
placeholder="Enter your GitHub Personal Access Token"
value={githubToken}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setGithubToken(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setGithubToken(e.target.value)
}
disabled={isLoading || isSaving}
className="w-full"
/>
</div>
{message && (
<div className={`p-3 rounded-md text-sm ${
message.type === 'success'
? 'bg-success/10 text-success-foreground border border-success/20'
: 'bg-error/10 text-error-foreground border border-error/20'
}`}>
<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()}
disabled={
isSaving || isLoading || !githubToken.trim()
}
className="flex-1"
>
{isSaving ? 'Saving...' : 'Save Token'}
{isSaving ? "Saving..." : "Save Token"}
</Button>
<Button
onClick={loadGithubToken}
disabled={isLoading || isSaving}
variant="outline"
>
{isLoading ? 'Loading...' : 'Refresh'}
{isLoading ? "Loading..." : "Refresh"}
</Button>
</div>
</div>
@@ -496,34 +603,38 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
</div>
)}
{activeTab === 'auth' && (
{activeTab === "auth" && (
<div className="space-y-4 sm:space-y-6">
<div>
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Authentication Settings</h3>
<p className="text-sm sm:text-base text-muted-foreground mb-4">
<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="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Authentication Status</h4>
<p className="text-sm text-muted-foreground mb-4">
{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.'
}
<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-sm font-medium text-foreground">Enable Authentication</p>
<p className="text-xs text-muted-foreground">
{authEnabled
? 'Authentication is required on every page load'
: 'Authentication is optional'
}
<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
@@ -536,15 +647,20 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
</div>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Update Credentials</h4>
<p className="text-sm text-muted-foreground mb-4">
<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="block text-sm font-medium text-foreground mb-1">
<label
htmlFor="auth-username"
className="text-foreground mb-1 block text-sm font-medium"
>
Username
</label>
<Input
@@ -552,15 +668,20 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
type="text"
placeholder="Enter username"
value={authUsername}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthUsername(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setAuthUsername(e.target.value)
}
disabled={authLoading}
className="w-full"
minLength={3}
/>
</div>
<div>
<label htmlFor="auth-password" className="block text-sm font-medium text-foreground mb-1">
<label
htmlFor="auth-password"
className="text-foreground mb-1 block text-sm font-medium"
>
New Password
</label>
<Input
@@ -568,15 +689,20 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
type="password"
placeholder="Enter new password"
value={authPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthPassword(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setAuthPassword(e.target.value)
}
disabled={authLoading}
className="w-full"
minLength={6}
/>
</div>
<div>
<label htmlFor="auth-confirm-password" className="block text-sm font-medium text-foreground mb-1">
<label
htmlFor="auth-confirm-password"
className="text-foreground mb-1 block text-sm font-medium"
>
Confirm Password
</label>
<Input
@@ -584,37 +710,46 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
type="password"
placeholder="Confirm new password"
value={authConfirmPassword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthConfirmPassword(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setAuthConfirmPassword(e.target.value)
}
disabled={authLoading}
className="w-full"
minLength={6}
/>
</div>
{message && (
<div className={`p-3 rounded-md text-sm ${
message.type === 'success'
? 'bg-success/10 text-success-foreground border border-success/20'
: 'bg-error/10 text-error-foreground border border-error/20'
}`}>
<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()}
disabled={
authLoading ||
!authUsername.trim() ||
!authPassword.trim() ||
!authConfirmPassword.trim()
}
className="flex-1"
>
{authLoading ? 'Saving...' : 'Update Credentials'}
{authLoading ? "Saving..." : "Update Credentials"}
</Button>
<Button
onClick={loadAuthCredentials}
disabled={authLoading}
variant="outline"
>
{authLoading ? 'Loading...' : 'Refresh'}
{authLoading ? "Loading..." : "Refresh"}
</Button>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

@@ -0,0 +1,266 @@
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',
},
},
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',
},
},
};

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

@@ -0,0 +1,266 @@
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',
},
},
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',
},
},
};

View File

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

View File

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

View File

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

View File

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