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:
34
package-lock.json
generated
34
package-lock.json
generated
@@ -5015,6 +5015,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.23.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
|
||||
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.26.3",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
|
||||
@@ -5049,6 +5055,16 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/node-pty": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
|
||||
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nan": "^2.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
@@ -9585,12 +9601,6 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.23.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
|
||||
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -9719,16 +9729,6 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-pty": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
|
||||
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nan": "^2.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.23",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
|
||||
@@ -13128,4 +13128,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,4 +90,4 @@
|
||||
"overrides": {
|
||||
"prismjs": "^1.30.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@/lib/i18n/useTranslation";
|
||||
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
||||
|
||||
interface CategorySidebarProps {
|
||||
categories: string[];
|
||||
@@ -12,218 +13,509 @@ interface CategorySidebarProps {
|
||||
}
|
||||
|
||||
// Icon mapping for categories
|
||||
const CategoryIcon = ({ iconName, className = "w-5 h-5" }: { iconName: string; className?: string }) => {
|
||||
const CategoryIcon = ({
|
||||
iconName,
|
||||
className = "w-5 h-5",
|
||||
}: {
|
||||
iconName: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
const iconMap: Record<string, React.ReactElement> = {
|
||||
server: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
monitor: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
box: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
shield: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
"shield-check": (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
key: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1 0 21 9z" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1 0 21 9z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
archive: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
database: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
"chart-bar": (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
template: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
"folder-open": (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
"document-text": (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
film: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2m0 0V1.5a.5.5 0 01.5-.5h1a.5.5 0 01.5.5V4m-3 0H9m3 0v16a1 1 0 01-1 1H8a1 1 0 01-1-1V4m6 0h2a2 2 0 012 2v12a2 2 0 01-2 2h-2V4z" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2m0 0V1.5a.5.5 0 01.5-.5h1a.5.5 0 01.5.5V4m-3 0H9m3 0v16a1 1 0 01-1 1H8a1 1 0 01-1-1V4m6 0h2a2 2 0 012 2v12a2 2 0 01-2 2h-2V4z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
download: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
"video-camera": (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
home: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
wifi: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
"chat-alt": (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
clock: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
code: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
"external-link": (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
sparkles: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
"currency-dollar": (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
puzzle: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
office: (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
return iconMap[iconName] ?? (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5a2 2 0 00-2-2h-4a2 2 0 00-2 2v12a4 4 0 004 4 4 4 0 004-4V5z" />
|
||||
</svg>
|
||||
return (
|
||||
iconMap[iconName] ?? (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5a2 2 0 00-2-2h-4a2 2 0 00-2 2v12a4 4 0 004 4 4 4 0 004-4V5z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export function CategorySidebar({
|
||||
categories,
|
||||
categoryCounts,
|
||||
totalScripts,
|
||||
selectedCategory,
|
||||
onCategorySelect
|
||||
export function CategorySidebar({
|
||||
categories,
|
||||
categoryCounts,
|
||||
totalScripts,
|
||||
selectedCategory,
|
||||
onCategorySelect,
|
||||
}: CategorySidebarProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const { t } = useTranslation("categorySidebar");
|
||||
|
||||
const formatCategoryLabel = (category: string) => {
|
||||
const defaultLabel = category.replace(/[_-]/g, " ");
|
||||
return t(`categories.${category}`, { fallback: defaultLabel });
|
||||
};
|
||||
|
||||
const formatCategoryTooltip = (categoryLabel: string, count: number) =>
|
||||
t("tooltips.category", { values: { category: categoryLabel, count } });
|
||||
|
||||
// Category to icon mapping (based on metadata.json)
|
||||
const categoryIconMapping: Record<string, string> = {
|
||||
'Proxmox & Virtualization': 'server',
|
||||
'Operating Systems': 'monitor',
|
||||
'Containers & Docker': 'box',
|
||||
'Network & Firewall': 'shield',
|
||||
'Adblock & DNS': 'shield-check',
|
||||
'Authentication & Security': 'key',
|
||||
'Backup & Recovery': 'archive',
|
||||
'Databases': 'database',
|
||||
'Monitoring & Analytics': 'chart-bar',
|
||||
'Dashboards & Frontends': 'template',
|
||||
'Files & Downloads': 'folder-open',
|
||||
'Documents & Notes': 'document-text',
|
||||
'Media & Streaming': 'film',
|
||||
'*Arr Suite': 'download',
|
||||
'NVR & Cameras': 'video-camera',
|
||||
'IoT & Smart Home': 'home',
|
||||
'ZigBee, Z-Wave & Matter': 'wifi',
|
||||
'MQTT & Messaging': 'chat-alt',
|
||||
'Automation & Scheduling': 'clock',
|
||||
'AI / Coding & Dev-Tools': 'code',
|
||||
'Webservers & Proxies': 'external-link',
|
||||
'Bots & ChatOps': 'sparkles',
|
||||
'Finance & Budgeting': 'currency-dollar',
|
||||
'Gaming & Leisure': 'puzzle',
|
||||
'Business & ERP': 'office',
|
||||
'Miscellaneous': 'box'
|
||||
"Proxmox & Virtualization": "server",
|
||||
"Operating Systems": "monitor",
|
||||
"Containers & Docker": "box",
|
||||
"Network & Firewall": "shield",
|
||||
"Adblock & DNS": "shield-check",
|
||||
"Authentication & Security": "key",
|
||||
"Backup & Recovery": "archive",
|
||||
Databases: "database",
|
||||
"Monitoring & Analytics": "chart-bar",
|
||||
"Dashboards & Frontends": "template",
|
||||
"Files & Downloads": "folder-open",
|
||||
"Documents & Notes": "document-text",
|
||||
"Media & Streaming": "film",
|
||||
"*Arr Suite": "download",
|
||||
"NVR & Cameras": "video-camera",
|
||||
"IoT & Smart Home": "home",
|
||||
"ZigBee, Z-Wave & Matter": "wifi",
|
||||
"MQTT & Messaging": "chat-alt",
|
||||
"Automation & Scheduling": "clock",
|
||||
"AI / Coding & Dev-Tools": "code",
|
||||
"Webservers & Proxies": "external-link",
|
||||
"Bots & ChatOps": "sparkles",
|
||||
"Finance & Budgeting": "currency-dollar",
|
||||
"Gaming & Leisure": "puzzle",
|
||||
"Business & ERP": "office",
|
||||
Miscellaneous: "box",
|
||||
};
|
||||
|
||||
// Sort categories by count (descending) and then alphabetically
|
||||
const sortedCategories = categories
|
||||
.map(category => [category, categoryCounts[category] ?? 0] as const)
|
||||
.map((category) => [category, categoryCounts[category] ?? 0] as const)
|
||||
.sort(([a, countA], [b, countB]) => {
|
||||
if (countB !== countA) return countB - countA;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`bg-card rounded-lg shadow-md border border-border transition-all duration-300 ${
|
||||
isCollapsed ? 'w-16' : 'w-full lg:w-80'
|
||||
}`}>
|
||||
<div
|
||||
className={`bg-card border-border rounded-lg border shadow-md transition-all duration-300 ${
|
||||
isCollapsed ? "w-16" : "w-full lg:w-80"
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div className="border-border flex items-center justify-between border-b p-4">
|
||||
{!isCollapsed && (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">Categories</h3>
|
||||
<p className="text-sm text-muted-foreground">{totalScripts} Total scripts</p>
|
||||
<h3 className="text-foreground text-lg font-semibold">
|
||||
{t("headerTitle")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("totalScripts", { values: { count: totalScripts } })}
|
||||
</p>
|
||||
</div>
|
||||
<ContextualHelpIcon section="available-scripts" tooltip="Help with categories" />
|
||||
<ContextualHelpIcon
|
||||
section="available-scripts"
|
||||
tooltip={t("helpTooltip")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="p-2 rounded-lg hover:bg-muted transition-colors"
|
||||
title={isCollapsed ? 'Expand categories' : 'Collapse categories'}
|
||||
className="hover:bg-muted rounded-lg p-2 transition-colors"
|
||||
title={isCollapsed ? t("actions.expand") : t("actions.collapse")}
|
||||
>
|
||||
<svg
|
||||
className={`w-5 h-5 text-muted-foreground transition-transform ${
|
||||
isCollapsed ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
<svg
|
||||
className={`text-muted-foreground h-5 w-5 transition-transform ${
|
||||
isCollapsed ? "rotate-180" : ""
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -235,24 +527,26 @@ export function CategorySidebar({
|
||||
{/* "All Categories" option */}
|
||||
<button
|
||||
onClick={() => onCategorySelect(null)}
|
||||
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
|
||||
selectedCategory === null
|
||||
? 'bg-primary/10 text-primary border border-primary/20'
|
||||
: 'hover:bg-accent text-muted-foreground'
|
||||
}`}
|
||||
className={`flex w-full items-center justify-between rounded-lg p-3 text-left transition-colors ${
|
||||
selectedCategory === null
|
||||
? "bg-primary/10 text-primary border-primary/20 border"
|
||||
: "hover:bg-accent text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<CategoryIcon
|
||||
iconName="template"
|
||||
className={`w-5 h-5 ${selectedCategory === null ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
<CategoryIcon
|
||||
iconName="template"
|
||||
className={`h-5 w-5 ${selectedCategory === null ? "text-primary" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<span className="font-medium">All Categories</span>
|
||||
<span className="font-medium">{t("all.label")}</span>
|
||||
</div>
|
||||
<span className={`text-sm px-2 py-1 rounded-full ${
|
||||
selectedCategory === null
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
<span
|
||||
className={`rounded-full px-2 py-1 text-sm ${
|
||||
selectedCategory === null
|
||||
? "bg-primary/20 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{totalScripts}
|
||||
</span>
|
||||
</button>
|
||||
@@ -260,31 +554,32 @@ export function CategorySidebar({
|
||||
{/* Individual Categories */}
|
||||
{sortedCategories.map(([category, count]) => {
|
||||
const isSelected = selectedCategory === category;
|
||||
|
||||
const categoryLabel = formatCategoryLabel(category);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => onCategorySelect(category)}
|
||||
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
|
||||
className={`flex w-full items-center justify-between rounded-lg p-3 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'bg-primary/10 text-primary border border-primary/20'
|
||||
: 'hover:bg-accent text-muted-foreground'
|
||||
? "bg-primary/10 text-primary border-primary/20 border"
|
||||
: "hover:bg-accent text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<CategoryIcon
|
||||
iconName={categoryIconMapping[category] ?? 'box'}
|
||||
className={`w-5 h-5 ${isSelected ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
<CategoryIcon
|
||||
iconName={categoryIconMapping[category] ?? "box"}
|
||||
className={`h-5 w-5 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<span className="font-medium capitalize">
|
||||
{category.replace(/[_-]/g, ' ')}
|
||||
</span>
|
||||
<span className="font-medium">{categoryLabel}</span>
|
||||
</div>
|
||||
<span className={`text-sm px-2 py-1 rounded-full ${
|
||||
isSelected
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
<span
|
||||
className={`rounded-full px-2 py-1 text-sm ${
|
||||
isSelected
|
||||
? "bg-primary/20 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
@@ -296,66 +591,71 @@ export function CategorySidebar({
|
||||
|
||||
{/* Collapsed state - show only icons with counters and tooltips */}
|
||||
{isCollapsed && (
|
||||
<div className="p-2 flex flex-row lg:flex-col space-x-2 lg:space-x-0 lg:space-y-2 overflow-x-auto lg:overflow-x-visible">
|
||||
<div className="flex flex-row space-x-2 overflow-x-auto p-2 lg:flex-col lg:space-y-2 lg:space-x-0 lg:overflow-x-visible">
|
||||
{/* "All Categories" option */}
|
||||
<div className="group relative">
|
||||
<button
|
||||
onClick={() => onCategorySelect(null)}
|
||||
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${
|
||||
selectedCategory === null
|
||||
? 'bg-primary/10 text-primary border border-primary/20'
|
||||
: 'hover:bg-accent text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<CategoryIcon
|
||||
iconName="template"
|
||||
className={`w-5 h-5 ${selectedCategory === null ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||
/>
|
||||
<span className={`text-xs mt-1 px-1 rounded ${
|
||||
className={`relative flex h-12 w-12 flex-col items-center justify-center rounded-lg transition-colors ${
|
||||
selectedCategory === null
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
? "bg-primary/10 text-primary border-primary/20 border"
|
||||
: "hover:bg-accent text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<CategoryIcon
|
||||
iconName="template"
|
||||
className={`h-5 w-5 ${selectedCategory === null ? "text-primary" : "text-muted-foreground group-hover:text-foreground"}`}
|
||||
/>
|
||||
<span
|
||||
className={`mt-1 rounded px-1 text-xs ${
|
||||
selectedCategory === null
|
||||
? "bg-primary/20 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{totalScripts}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
||||
{/* Tooltip */}
|
||||
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-popover text-popover-foreground text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
|
||||
All Categories ({totalScripts})
|
||||
<div className="bg-popover text-popover-foreground pointer-events-none absolute top-1/2 left-full z-50 ml-2 hidden -translate-y-1/2 transform rounded px-2 py-1 text-sm whitespace-nowrap opacity-0 transition-opacity group-hover:opacity-100 lg:block">
|
||||
{t("all.tooltip", { values: { count: totalScripts } })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Individual Categories */}
|
||||
{sortedCategories.map(([category, count]) => {
|
||||
const isSelected = selectedCategory === category;
|
||||
|
||||
const categoryLabel = formatCategoryLabel(category);
|
||||
|
||||
return (
|
||||
<div key={category} className="group relative">
|
||||
<button
|
||||
onClick={() => onCategorySelect(category)}
|
||||
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center transition-colors relative ${
|
||||
className={`relative flex h-12 w-12 flex-col items-center justify-center rounded-lg transition-colors ${
|
||||
isSelected
|
||||
? 'bg-primary/10 text-primary border border-primary/20'
|
||||
: 'hover:bg-accent text-muted-foreground'
|
||||
? "bg-primary/10 text-primary border-primary/20 border"
|
||||
: "hover:bg-accent text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<CategoryIcon
|
||||
iconName={categoryIconMapping[category] ?? 'box'}
|
||||
className={`w-5 h-5 ${isSelected ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||
<CategoryIcon
|
||||
iconName={categoryIconMapping[category] ?? "box"}
|
||||
className={`h-5 w-5 ${isSelected ? "text-primary" : "text-muted-foreground group-hover:text-foreground"}`}
|
||||
/>
|
||||
<span className={`text-xs mt-1 px-1 rounded ${
|
||||
isSelected
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
<span
|
||||
className={`mt-1 rounded px-1 text-xs ${
|
||||
isSelected
|
||||
? "bg-primary/20 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
||||
{/* Tooltip */}
|
||||
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-popover text-popover-foreground text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
|
||||
{category} ({count})
|
||||
<div className="bg-popover text-popover-foreground pointer-events-none absolute top-1/2 left-full z-50 ml-2 hidden -translate-y-1/2 transform rounded px-2 py-1 text-sm whitespace-nowrap opacity-0 transition-opacity group-hover:opacity-100 lg:block">
|
||||
{formatCategoryTooltip(categoryLabel, count)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -364,4 +664,4 @@ export function CategorySidebar({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
||||
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter } from "lucide-react";
|
||||
import {
|
||||
Package,
|
||||
Monitor,
|
||||
Wrench,
|
||||
Server,
|
||||
FileText,
|
||||
Calendar,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "~/lib/i18n/useTranslation";
|
||||
|
||||
export interface FilterState {
|
||||
searchQuery: string;
|
||||
@@ -24,10 +34,10 @@ interface FilterBarProps {
|
||||
}
|
||||
|
||||
const SCRIPT_TYPES = [
|
||||
{ value: "ct", label: "LXC Container", Icon: Package },
|
||||
{ value: "vm", label: "Virtual Machine", Icon: Monitor },
|
||||
{ value: "addon", label: "Add-on", Icon: Wrench },
|
||||
{ value: "pve", label: "PVE Host", Icon: Server },
|
||||
{ value: "ct", labelKey: "types.options.ct", Icon: Package },
|
||||
{ value: "vm", labelKey: "types.options.vm", Icon: Monitor },
|
||||
{ value: "addon", labelKey: "types.options.addon", Icon: Wrench },
|
||||
{ value: "pve", labelKey: "types.options.pve", Icon: Server },
|
||||
];
|
||||
|
||||
export function FilterBar({
|
||||
@@ -39,6 +49,7 @@ export function FilterBar({
|
||||
saveFiltersEnabled = false,
|
||||
isLoadingFilters = false,
|
||||
}: FilterBarProps) {
|
||||
const { t } = useTranslation("filterBar");
|
||||
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
|
||||
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
|
||||
|
||||
@@ -64,50 +75,55 @@ export function FilterBar({
|
||||
filters.sortOrder !== "asc";
|
||||
|
||||
const getUpdatableButtonText = () => {
|
||||
if (filters.showUpdatable === null) return "Updatable: All";
|
||||
if (filters.showUpdatable === true)
|
||||
return `Updatable: Yes (${updatableCount})`;
|
||||
return "Updatable: No";
|
||||
if (filters.showUpdatable === null) return t("updatable.all");
|
||||
if (filters.showUpdatable === true) {
|
||||
return t("updatable.yes", { values: { count: updatableCount } });
|
||||
}
|
||||
return t("updatable.no");
|
||||
};
|
||||
|
||||
const getTypeButtonText = () => {
|
||||
if (filters.selectedTypes.length === 0) return "All Types";
|
||||
if (filters.selectedTypes.length === 0) return t("types.all");
|
||||
if (filters.selectedTypes.length === 1) {
|
||||
const type = SCRIPT_TYPES.find(
|
||||
(t) => t.value === filters.selectedTypes[0],
|
||||
);
|
||||
return type?.label ?? filters.selectedTypes[0];
|
||||
return type ? t(type.labelKey) : filters.selectedTypes[0];
|
||||
}
|
||||
return `${filters.selectedTypes.length} Types`;
|
||||
return t("types.multiple", {
|
||||
values: { count: filters.selectedTypes.length },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
|
||||
<div className="border-border bg-card mb-6 rounded-lg border p-4 shadow-sm sm:p-6">
|
||||
{/* Loading State */}
|
||||
{isLoadingFilters && (
|
||||
<div className="mb-4 flex items-center justify-center py-2">
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||
<span>Loading saved filters...</span>
|
||||
<div className="text-muted-foreground flex items-center space-x-2 text-sm">
|
||||
<div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent"></div>
|
||||
<span>{t("loading")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Filter Header */}
|
||||
{!isLoadingFilters && (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
|
||||
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
|
||||
<h3 className="text-foreground text-lg font-medium">{t("header")}</h3>
|
||||
<ContextualHelpIcon
|
||||
section="available-scripts"
|
||||
tooltip={t("helpTooltip")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="relative max-w-md w-full">
|
||||
<div className="relative w-full max-w-md">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<svg
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -122,17 +138,17 @@ export function FilterBar({
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search scripts..."
|
||||
placeholder={t("search.placeholder")}
|
||||
value={filters.searchQuery}
|
||||
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
|
||||
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
|
||||
className="border-input bg-background text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-primary block w-full rounded-lg border py-3 pr-10 pl-10 text-sm leading-5 focus:ring-2 focus:outline-none"
|
||||
/>
|
||||
{filters.searchQuery && (
|
||||
<Button
|
||||
onClick={() => updateFilters({ searchQuery: "" })}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute inset-y-0 right-0 pr-3 text-muted-foreground hover:text-foreground"
|
||||
className="text-muted-foreground hover:text-foreground absolute inset-y-0 right-0 pr-3"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
@@ -153,7 +169,7 @@ export function FilterBar({
|
||||
</div>
|
||||
|
||||
{/* Filter Buttons */}
|
||||
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
|
||||
<div className="mb-4 flex flex-col flex-wrap gap-2 sm:flex-row sm:gap-3">
|
||||
{/* Updateable Filter */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -167,12 +183,12 @@ export function FilterBar({
|
||||
}}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
|
||||
className={`flex w-full items-center justify-center space-x-2 sm:w-auto ${
|
||||
filters.showUpdatable === null
|
||||
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
: filters.showUpdatable === true
|
||||
? "border border-success/20 bg-success/10 text-success"
|
||||
: "border border-destructive/20 bg-destructive/10 text-destructive"
|
||||
? "border-success/20 bg-success/10 text-success border"
|
||||
: "border-destructive/20 bg-destructive/10 text-destructive border"
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
@@ -185,10 +201,10 @@ export function FilterBar({
|
||||
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`w-full flex items-center justify-center space-x-2 ${
|
||||
className={`flex w-full items-center justify-center space-x-2 ${
|
||||
filters.selectedTypes.length === 0
|
||||
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
: "border border-primary/20 bg-primary/10 text-primary"
|
||||
: "border-primary/20 bg-primary/10 text-primary border"
|
||||
}`}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
@@ -209,14 +225,14 @@ export function FilterBar({
|
||||
</Button>
|
||||
|
||||
{isTypeDropdownOpen && (
|
||||
<div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-border bg-card shadow-lg">
|
||||
<div className="border-border bg-card absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border shadow-lg">
|
||||
<div className="p-2">
|
||||
{SCRIPT_TYPES.map((type) => {
|
||||
const IconComponent = type.Icon;
|
||||
return (
|
||||
<label
|
||||
key={type.value}
|
||||
className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-accent"
|
||||
className="hover:bg-accent flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -237,17 +253,17 @@ export function FilterBar({
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="rounded border-input text-primary focus:ring-primary"
|
||||
className="border-input text-primary focus:ring-primary rounded"
|
||||
/>
|
||||
<IconComponent className="h-4 w-4" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{type.label}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t(type.labelKey)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t border-border p-2">
|
||||
<div className="border-border border-t p-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
updateFilters({ selectedTypes: [] });
|
||||
@@ -255,9 +271,9 @@ export function FilterBar({
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
className="text-muted-foreground hover:bg-accent hover:text-foreground w-full justify-start"
|
||||
>
|
||||
Clear all
|
||||
{t("actions.clearAllTypes")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -270,14 +286,18 @@ export function FilterBar({
|
||||
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-2 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
className="bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground flex w-full items-center justify-center space-x-2 sm:w-auto"
|
||||
>
|
||||
{filters.sortBy === "name" ? (
|
||||
<FileText className="h-4 w-4" />
|
||||
) : (
|
||||
<Calendar className="h-4 w-4" />
|
||||
)}
|
||||
<span>{filters.sortBy === "name" ? "By Name" : "By Created Date"}</span>
|
||||
<span>
|
||||
{filters.sortBy === "name"
|
||||
? t("sort.byName")
|
||||
: t("sort.byCreated")}
|
||||
</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
@@ -294,31 +314,35 @@ export function FilterBar({
|
||||
</Button>
|
||||
|
||||
{isSortDropdownOpen && (
|
||||
<div className="absolute top-full left-0 z-10 mt-1 w-full sm:w-48 rounded-lg border border-border bg-card shadow-lg">
|
||||
<div className="border-border bg-card absolute top-full left-0 z-10 mt-1 w-full rounded-lg border shadow-lg sm:w-48">
|
||||
<div className="p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
updateFilters({ sortBy: "name" });
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
|
||||
filters.sortBy === "name" ? "bg-primary/10 text-primary" : "text-muted-foreground"
|
||||
className={`hover:bg-accent flex w-full items-center space-x-3 rounded-md px-3 py-2 text-left ${
|
||||
filters.sortBy === "name"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm">By Name</span>
|
||||
<span className="text-sm">{t("sort.byName")}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
updateFilters({ sortBy: "created" });
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
|
||||
filters.sortBy === "created" ? "bg-primary/10 text-primary" : "text-muted-foreground"
|
||||
className={`hover:bg-accent flex w-full items-center space-x-3 rounded-md px-3 py-2 text-left ${
|
||||
filters.sortBy === "created"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="text-sm">By Created Date</span>
|
||||
<span className="text-sm">{t("sort.byCreated")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,7 +358,7 @@ export function FilterBar({
|
||||
}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-1 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
className="bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground flex w-full items-center justify-center space-x-1 sm:w-auto"
|
||||
>
|
||||
{filters.sortOrder === "asc" ? (
|
||||
<>
|
||||
@@ -352,7 +376,9 @@ export function FilterBar({
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{filters.sortBy === "created" ? "Oldest First" : "A-Z"}
|
||||
{filters.sortBy === "created"
|
||||
? t("sort.oldestFirst")
|
||||
: t("sort.aToZ")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -371,7 +397,9 @@ export function FilterBar({
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{filters.sortBy === "created" ? "Newest First" : "Z-A"}
|
||||
{filters.sortBy === "created"
|
||||
? t("sort.newestFirst")
|
||||
: t("sort.zToA")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -379,30 +407,38 @@ export function FilterBar({
|
||||
</div>
|
||||
|
||||
{/* Filter Summary and Clear All */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{filteredCount === totalScripts ? (
|
||||
<span>Showing all {totalScripts} scripts</span>
|
||||
<span>
|
||||
{t("summary.showingAll", { values: { count: totalScripts } })}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{filteredCount} of {totalScripts} scripts{" "}
|
||||
{t("summary.showingFiltered", {
|
||||
values: { filtered: filteredCount, total: totalScripts },
|
||||
})}{" "}
|
||||
{hasActiveFilters && (
|
||||
<span className="font-medium text-info">
|
||||
(filtered)
|
||||
<span className="text-info font-medium">
|
||||
{t("summary.filteredSuffix")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Filter Persistence Status */}
|
||||
{!isLoadingFilters && saveFiltersEnabled && (
|
||||
<div className="flex items-center space-x-1 text-xs text-success">
|
||||
<div className="text-success flex items-center space-x-1 text-xs">
|
||||
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>Filters are being saved automatically</span>
|
||||
<span>{t("persistence.enabled")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -412,7 +448,7 @@ export function FilterBar({
|
||||
onClick={clearAllFilters}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center space-x-1 text-error hover:bg-error/10 hover:text-error-foreground w-full sm:w-auto justify-center sm:justify-start"
|
||||
className="text-error hover:bg-error/10 hover:text-error-foreground flex w-full items-center justify-center space-x-1 sm:w-auto sm:justify-start"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
@@ -427,7 +463,7 @@ export function FilterBar({
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<span>Clear all filters</span>
|
||||
<span>{t("actions.clearFilters")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { ExternalLink, FileText } from 'lucide-react';
|
||||
import { api } from "~/trpc/react";
|
||||
import { Button } from "./ui/button";
|
||||
import { ExternalLink, FileText } from "lucide-react";
|
||||
import { useTranslation } from "~/lib/i18n/useTranslation";
|
||||
|
||||
interface FooterProps {
|
||||
onOpenReleaseNotes: () => void;
|
||||
@@ -10,41 +11,43 @@ interface FooterProps {
|
||||
|
||||
export function Footer({ onOpenReleaseNotes }: FooterProps) {
|
||||
const { data: versionData } = api.version.getCurrentVersion.useQuery();
|
||||
const { t } = useTranslation("footer");
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="sticky bottom-0 mt-auto border-t border-border bg-muted/30 py-3 backdrop-blur-sm">
|
||||
<footer className="border-border bg-muted/30 sticky bottom-0 mt-auto border-t py-3 backdrop-blur-sm">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-between gap-2 text-sm sm:flex-row">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>© 2024 PVE Scripts Local</span>
|
||||
<span>{t("copyright", { values: { year: currentYear } })}</span>
|
||||
{versionData?.success && versionData.version && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onOpenReleaseNotes}
|
||||
className="h-auto p-1 text-xs hover:text-foreground"
|
||||
className="hover:text-foreground h-auto p-1 text-xs"
|
||||
>
|
||||
v{versionData.version}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onOpenReleaseNotes}
|
||||
className="h-auto p-2 text-xs hover:text-foreground"
|
||||
className="hover:text-foreground h-auto p-2 text-xs"
|
||||
>
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
Release Notes
|
||||
<FileText className="mr-1 h-3 w-3" />
|
||||
{t("releaseNotes")}
|
||||
</Button>
|
||||
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="h-auto p-2 text-xs hover:text-foreground"
|
||||
className="hover:text-foreground h-auto p-2 text-xs"
|
||||
>
|
||||
<a
|
||||
href="https://github.com/community-scripts/ProxmoxVE-Local"
|
||||
@@ -53,7 +56,7 @@ export function Footer({ onOpenReleaseNotes }: FooterProps) {
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
GitHub
|
||||
{t("github")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
765
src/app/_components/GeneralSettingsModal.tsx.backup
Normal file
765
src/app/_components/GeneralSettingsModal.tsx.backup
Normal file
@@ -0,0 +1,765 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Toggle } from "./ui/toggle";
|
||||
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { useRegisterModal } from "./modal/ModalStackProvider";
|
||||
import { useTranslation } from "~/lib/i18n/useTranslation";
|
||||
|
||||
interface GeneralSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GeneralSettingsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: GeneralSettingsModalProps) {
|
||||
useRegisterModal(isOpen, {
|
||||
id: "general-settings-modal",
|
||||
allowEscape: true,
|
||||
onClose,
|
||||
});
|
||||
const { t, locale, setLocale, availableLocales } = useTranslation("settings");
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [activeTab, setActiveTab] = useState<"general" | "github" | "auth">(
|
||||
"general",
|
||||
);
|
||||
const [githubToken, setGithubToken] = useState("");
|
||||
const [saveFilter, setSaveFilter] = useState(false);
|
||||
const [savedFilters, setSavedFilters] = useState<any>(null);
|
||||
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
// Auth state
|
||||
const [authUsername, setAuthUsername] = useState("");
|
||||
const [authPassword, setAuthPassword] = useState("");
|
||||
const [authConfirmPassword, setAuthConfirmPassword] = useState("");
|
||||
const [authEnabled, setAuthEnabled] = useState(false);
|
||||
const [authHasCredentials, setAuthHasCredentials] = useState(false);
|
||||
const [authSetupCompleted, setAuthSetupCompleted] = useState(false);
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
|
||||
// Load existing settings when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadGithubToken();
|
||||
void loadSaveFilter();
|
||||
void loadSavedFilters();
|
||||
void loadAuthCredentials();
|
||||
void loadColorCodingSetting();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadGithubToken = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/settings/github-token");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setGithubToken((data.token as string) ?? "");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading GitHub token:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSaveFilter = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/settings/save-filter");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSaveFilter((data.enabled as boolean) ?? false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading save filter setting:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSaveFilter = async (enabled: boolean) => {
|
||||
try {
|
||||
const response = await fetch("/api/settings/save-filter", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSaveFilter(enabled);
|
||||
setMessage({ type: "success", text: "Save filter setting updated!" });
|
||||
|
||||
// If disabling save filters, clear saved filters
|
||||
if (!enabled) {
|
||||
await clearSavedFilters();
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: errorData.error ?? "Failed to save setting",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Failed to save setting" });
|
||||
}
|
||||
};
|
||||
|
||||
const loadSavedFilters = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/settings/filters");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSavedFilters(data.filters);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading saved filters:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSavedFilters = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/settings/filters", {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSavedFilters(null);
|
||||
setMessage({ type: "success", text: "Saved filters cleared!" });
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: errorData.error ?? "Failed to clear filters",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Failed to clear filters" });
|
||||
}
|
||||
};
|
||||
|
||||
const saveGithubToken = async () => {
|
||||
setIsSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/settings/github-token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ token: githubToken }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: "GitHub token saved successfully!",
|
||||
});
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: errorData.error ?? "Failed to save token",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Failed to save token" });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadColorCodingSetting = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/settings/color-coding");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setColorCodingEnabled(Boolean(data.enabled));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading color coding setting:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveColorCodingSetting = async (enabled: boolean) => {
|
||||
try {
|
||||
const response = await fetch("/api/settings/color-coding", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setColorCodingEnabled(enabled);
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: "Color coding setting saved successfully",
|
||||
});
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} else {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: "Failed to save color coding setting",
|
||||
});
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving color coding setting:", error);
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: "Failed to save color coding setting",
|
||||
});
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAuthCredentials = async () => {
|
||||
setAuthLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/settings/auth-credentials");
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as {
|
||||
username: string;
|
||||
enabled: boolean;
|
||||
hasCredentials: boolean;
|
||||
setupCompleted: boolean;
|
||||
};
|
||||
setAuthUsername(data.username ?? "");
|
||||
setAuthEnabled(data.enabled ?? false);
|
||||
setAuthHasCredentials(data.hasCredentials ?? false);
|
||||
setAuthSetupCompleted(data.setupCompleted ?? false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading auth credentials:", error);
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAuthCredentials = async () => {
|
||||
if (authPassword !== authConfirmPassword) {
|
||||
setMessage({ type: "error", text: "Passwords do not match" });
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/settings/auth-credentials", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: authUsername,
|
||||
password: authPassword,
|
||||
enabled: authEnabled,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: "Authentication credentials updated successfully!",
|
||||
});
|
||||
setAuthPassword("");
|
||||
setAuthConfirmPassword("");
|
||||
void loadAuthCredentials();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: errorData.error ?? "Failed to save credentials",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Failed to save credentials" });
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAuthEnabled = async (enabled: boolean) => {
|
||||
setAuthLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/settings/auth-credentials", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setAuthEnabled(enabled);
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: `Authentication ${enabled ? "enabled" : "disabled"} successfully!`,
|
||||
});
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: errorData.error ?? "Failed to update auth status",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Failed to update auth status" });
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-2 backdrop-blur-sm sm:p-4">
|
||||
<div className="bg-card max-h-[95vh] w-full max-w-4xl overflow-hidden rounded-lg shadow-xl sm:max-h-[90vh]">
|
||||
{/* Header */}
|
||||
<div className="border-border flex items-center justify-between border-b p-4 sm:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-card-foreground text-xl font-bold sm:text-2xl">
|
||||
Settings
|
||||
</h2>
|
||||
<ContextualHelpIcon
|
||||
section="general-settings"
|
||||
tooltip="Help with General Settings"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 sm:h-6 sm:w-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-border border-b">
|
||||
<nav className="flex flex-col space-y-1 px-4 sm:flex-row sm:space-y-0 sm:space-x-8 sm:px-6">
|
||||
<Button
|
||||
onClick={() => setActiveTab("general")}
|
||||
variant="ghost"
|
||||
size="null"
|
||||
className={`w-full border-b-2 px-1 py-3 text-sm font-medium sm:w-auto sm:py-4 ${
|
||||
activeTab === "general"
|
||||
? "border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:border-border border-transparent"
|
||||
}`}
|
||||
>
|
||||
General
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveTab("github")}
|
||||
variant="ghost"
|
||||
size="null"
|
||||
className={`w-full border-b-2 px-1 py-3 text-sm font-medium sm:w-auto sm:py-4 ${
|
||||
activeTab === "github"
|
||||
? "border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:border-border border-transparent"
|
||||
}`}
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveTab("auth")}
|
||||
variant="ghost"
|
||||
size="null"
|
||||
className={`w-full border-b-2 px-1 py-3 text-sm font-medium sm:w-auto sm:py-4 ${
|
||||
activeTab === "auth"
|
||||
? "border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:border-border border-transparent"
|
||||
}`}
|
||||
>
|
||||
Authentication
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-h-[calc(95vh-180px)] overflow-y-auto p-4 sm:max-h-[calc(90vh-200px)] sm:p-6">
|
||||
{activeTab === "general" && (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<h3 className="text-foreground mb-3 text-base font-medium sm:mb-4 sm:text-lg">
|
||||
General Settings
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4 text-sm sm:text-base">
|
||||
Configure general application preferences and behavior.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="border-border rounded-lg border p-4">
|
||||
<h4 className="text-foreground mb-2 font-medium">Theme</h4>
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
Choose your preferred color theme for the application.
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
Current Theme
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{theme === "light" ? "Light mode" : "Dark mode"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => setTheme("light")}
|
||||
variant={theme === "light" ? "default" : "outline"}
|
||||
size="sm"
|
||||
>
|
||||
Light
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setTheme("dark")}
|
||||
variant={theme === "dark" ? "default" : "outline"}
|
||||
size="sm"
|
||||
>
|
||||
Dark
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border rounded-lg border p-4">
|
||||
<h4 className="text-foreground mb-2 font-medium">
|
||||
Save Filters
|
||||
</h4>
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
Save your configured script filters.
|
||||
</p>
|
||||
<Toggle
|
||||
checked={saveFilter}
|
||||
onCheckedChange={saveSaveFilter}
|
||||
label="Enable filter saving"
|
||||
/>
|
||||
|
||||
{saveFilter && (
|
||||
<div className="bg-muted mt-4 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
Saved Filters
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{savedFilters
|
||||
? "Filters are currently saved"
|
||||
: "No filters saved yet"}
|
||||
</p>
|
||||
{savedFilters && (
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
<div>
|
||||
Search: {savedFilters.searchQuery ?? "None"}
|
||||
</div>
|
||||
<div>
|
||||
Types: {savedFilters.selectedTypes?.length ?? 0}{" "}
|
||||
selected
|
||||
</div>
|
||||
<div>
|
||||
Sort: {savedFilters.sortBy} (
|
||||
{savedFilters.sortOrder})
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{savedFilters && (
|
||||
<Button
|
||||
onClick={clearSavedFilters}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-error hover:text-error/80"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-border rounded-lg border p-4">
|
||||
<h4 className="text-foreground mb-2 font-medium">
|
||||
Server Color Coding
|
||||
</h4>
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
Enable color coding for servers to visually distinguish them
|
||||
throughout the application.
|
||||
</p>
|
||||
<Toggle
|
||||
checked={colorCodingEnabled}
|
||||
onCheckedChange={saveColorCodingSetting}
|
||||
label="Enable server color coding"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "github" && (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h3 className="text-foreground mb-3 text-base font-medium sm:mb-4 sm:text-lg">
|
||||
GitHub Integration
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4 text-sm sm:text-base">
|
||||
Configure GitHub integration for script management and
|
||||
updates.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="border-border rounded-lg border p-4">
|
||||
<h4 className="text-foreground mb-2 font-medium">
|
||||
GitHub Personal Access Token
|
||||
</h4>
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
Save a GitHub Personal Access Token to circumvent GitHub
|
||||
API rate limits.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="github-token"
|
||||
className="text-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
Token
|
||||
</label>
|
||||
<Input
|
||||
id="github-token"
|
||||
type="password"
|
||||
placeholder="Enter your GitHub Personal Access Token"
|
||||
value={githubToken}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setGithubToken(e.target.value)
|
||||
}
|
||||
disabled={isLoading || isSaving}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`rounded-md p-3 text-sm ${
|
||||
message.type === "success"
|
||||
? "bg-success/10 text-success-foreground border-success/20 border"
|
||||
: "bg-error/10 text-error-foreground border-error/20 border"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={saveGithubToken}
|
||||
disabled={
|
||||
isSaving || isLoading || !githubToken.trim()
|
||||
}
|
||||
className="flex-1"
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save Token"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={loadGithubToken}
|
||||
disabled={isLoading || isSaving}
|
||||
variant="outline"
|
||||
>
|
||||
{isLoading ? "Loading..." : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "auth" && (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h3 className="text-foreground mb-3 text-base font-medium sm:mb-4 sm:text-lg">
|
||||
Authentication Settings
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4 text-sm sm:text-base">
|
||||
Configure authentication to secure access to your application.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="border-border rounded-lg border p-4">
|
||||
<h4 className="text-foreground mb-2 font-medium">
|
||||
Authentication Status
|
||||
</h4>
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
{authSetupCompleted
|
||||
? authHasCredentials
|
||||
? `Authentication is ${authEnabled ? "enabled" : "disabled"}. Current username: ${authUsername}`
|
||||
: `Authentication is ${authEnabled ? "enabled" : "disabled"}. No credentials configured.`
|
||||
: "Authentication setup has not been completed yet."}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
Enable Authentication
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{authEnabled
|
||||
? "Authentication is required on every page load"
|
||||
: "Authentication is optional"}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={authEnabled}
|
||||
onCheckedChange={toggleAuthEnabled}
|
||||
disabled={authLoading || !authSetupCompleted}
|
||||
label="Enable authentication"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border rounded-lg border p-4">
|
||||
<h4 className="text-foreground mb-2 font-medium">
|
||||
Update Credentials
|
||||
</h4>
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
Change your username and password for authentication.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="auth-username"
|
||||
className="text-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<Input
|
||||
id="auth-username"
|
||||
type="text"
|
||||
placeholder="Enter username"
|
||||
value={authUsername}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setAuthUsername(e.target.value)
|
||||
}
|
||||
disabled={authLoading}
|
||||
className="w-full"
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="auth-password"
|
||||
className="text-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<Input
|
||||
id="auth-password"
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
value={authPassword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setAuthPassword(e.target.value)
|
||||
}
|
||||
disabled={authLoading}
|
||||
className="w-full"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="auth-confirm-password"
|
||||
className="text-foreground mb-1 block text-sm font-medium"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<Input
|
||||
id="auth-confirm-password"
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
value={authConfirmPassword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setAuthConfirmPassword(e.target.value)
|
||||
}
|
||||
disabled={authLoading}
|
||||
className="w-full"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`rounded-md p-3 text-sm ${
|
||||
message.type === "success"
|
||||
? "bg-success/10 text-success-foreground border-success/20 border"
|
||||
: "bg-error/10 text-error-foreground border-error/20 border"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={saveAuthCredentials}
|
||||
disabled={
|
||||
authLoading ||
|
||||
!authUsername.trim() ||
|
||||
!authPassword.trim() ||
|
||||
!authConfirmPassword.trim()
|
||||
}
|
||||
className="flex-1"
|
||||
>
|
||||
{authLoading ? "Saving..." : "Update Credentials"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={loadAuthCredentials}
|
||||
disabled={authLoading}
|
||||
variant="outline"
|
||||
>
|
||||
{authLoading ? "Loading..." : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/app/_components/LanguageToggle.tsx
Normal file
50
src/app/_components/LanguageToggle.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Languages } from "lucide-react";
|
||||
|
||||
import { useTranslation } from "~/lib/i18n/useTranslation";
|
||||
import { type Locale, locales } from "~/lib/i18n/config";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface LanguageToggleProps {
|
||||
className?: string;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
function getNextLocale(current: Locale): Locale {
|
||||
const orderedLocales = [...locales];
|
||||
const currentIndex = orderedLocales.indexOf(current);
|
||||
const nextIndex =
|
||||
currentIndex === -1 ? 0 : (currentIndex + 1) % orderedLocales.length;
|
||||
const fallback = orderedLocales[0] ?? current;
|
||||
return orderedLocales[nextIndex] ?? fallback;
|
||||
}
|
||||
|
||||
export function LanguageToggle({
|
||||
className = "",
|
||||
showLabel = false,
|
||||
}: LanguageToggleProps) {
|
||||
const { locale, setLocale, t } = useTranslation("common.language");
|
||||
|
||||
const nextLocale = getNextLocale(locale);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setLocale(nextLocale);
|
||||
}, [nextLocale, setLocale]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleToggle}
|
||||
className={`text-muted-foreground hover:text-foreground transition-colors ${className}`}
|
||||
aria-label={t("switch")}
|
||||
>
|
||||
<Languages className="h-4 w-4" />
|
||||
{showLabel && (
|
||||
<span className="ml-2 text-sm">{locale.toUpperCase()}</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,10 @@ import "~/styles/globals.css";
|
||||
|
||||
import { type Metadata, type Viewport } from "next";
|
||||
import { Geist } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { LanguageProvider } from "~/lib/i18n/LanguageProvider";
|
||||
import { defaultLocale, isLocale } from "~/lib/i18n/config";
|
||||
import { TRPCReactProvider } from "~/trpc/react";
|
||||
import { AuthProvider } from "./_components/AuthProvider";
|
||||
import { AuthGuard } from "./_components/AuthGuard";
|
||||
@@ -11,7 +14,8 @@ import { ModalStackProvider } from "./_components/modal/ModalStackProvider";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PVE Scripts local",
|
||||
description: "Manage and execute Proxmox helper scripts locally with live output streaming",
|
||||
description:
|
||||
"Manage and execute Proxmox helper scripts locally with live output streaming",
|
||||
icons: [
|
||||
{ rel: "icon", url: "/favicon.png", type: "image/png" },
|
||||
{ rel: "icon", url: "/favicon.ico", sizes: "any" },
|
||||
@@ -30,26 +34,44 @@ const geist = Geist({
|
||||
variable: "--font-jetbrains-mono",
|
||||
});
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const headerList = await headers();
|
||||
const cookieHeader = headerList.get("cookie");
|
||||
let initialLocale = defaultLocale;
|
||||
|
||||
if (cookieHeader) {
|
||||
const localeEntry = cookieHeader
|
||||
.split(";")
|
||||
.map((entry: string) => entry.trim())
|
||||
.find((entry: string) => entry.startsWith("pve-locale="));
|
||||
|
||||
if (localeEntry) {
|
||||
const value = localeEntry.split("=")[1];
|
||||
if (isLocale(value)) {
|
||||
initialLocale = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang="en" className={geist.variable}>
|
||||
<body
|
||||
<html lang={initialLocale} className={geist.variable}>
|
||||
<body
|
||||
className="bg-background text-foreground transition-colors"
|
||||
suppressHydrationWarning={true}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<TRPCReactProvider>
|
||||
<AuthProvider>
|
||||
<ModalStackProvider>
|
||||
<AuthGuard>
|
||||
{children}
|
||||
</AuthGuard>
|
||||
</ModalStackProvider>
|
||||
</AuthProvider>
|
||||
</TRPCReactProvider>
|
||||
</ThemeProvider>
|
||||
<LanguageProvider initialLocale={initialLocale}>
|
||||
<ThemeProvider>
|
||||
<TRPCReactProvider>
|
||||
<AuthProvider>
|
||||
<ModalStackProvider>
|
||||
<AuthGuard>{children}</AuthGuard>
|
||||
</ModalStackProvider>
|
||||
</AuthProvider>
|
||||
</TRPCReactProvider>
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
243
src/app/page.tsx
243
src/app/page.tsx
@@ -1,47 +1,67 @@
|
||||
"use client";
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { ScriptsGrid } from './_components/ScriptsGrid';
|
||||
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
|
||||
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
|
||||
import { ResyncButton } from './_components/ResyncButton';
|
||||
import { Terminal } from './_components/Terminal';
|
||||
import { ServerSettingsButton } from './_components/ServerSettingsButton';
|
||||
import { SettingsButton } from './_components/SettingsButton';
|
||||
import { HelpButton } from './_components/HelpButton';
|
||||
import { VersionDisplay } from './_components/VersionDisplay';
|
||||
import { ThemeToggle } from './_components/ThemeToggle';
|
||||
import { Button } from './_components/ui/button';
|
||||
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
|
||||
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
|
||||
import { Footer } from './_components/Footer';
|
||||
import { Package, HardDrive, FolderOpen } from 'lucide-react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { ScriptsGrid } from "./_components/ScriptsGrid";
|
||||
import { DownloadedScriptsTab } from "./_components/DownloadedScriptsTab";
|
||||
import { InstalledScriptsTab } from "./_components/InstalledScriptsTab";
|
||||
import { ResyncButton } from "./_components/ResyncButton";
|
||||
import { Terminal } from "./_components/Terminal";
|
||||
import { ServerSettingsButton } from "./_components/ServerSettingsButton";
|
||||
import { SettingsButton } from "./_components/SettingsButton";
|
||||
import { HelpButton } from "./_components/HelpButton";
|
||||
import { VersionDisplay } from "./_components/VersionDisplay";
|
||||
import { ThemeToggle } from "./_components/ThemeToggle";
|
||||
import { LanguageToggle } from "./_components/LanguageToggle";
|
||||
import { Button } from "./_components/ui/button";
|
||||
import { ContextualHelpIcon } from "./_components/ContextualHelpIcon";
|
||||
import {
|
||||
ReleaseNotesModal,
|
||||
getLastSeenVersion,
|
||||
} from "./_components/ReleaseNotesModal";
|
||||
import { Footer } from "./_components/Footer";
|
||||
import { Package, HardDrive, FolderOpen } from "lucide-react";
|
||||
import { useTranslation } from "~/lib/i18n/useTranslation";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export default function Home() {
|
||||
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed';
|
||||
return savedTab || 'scripts';
|
||||
const { t } = useTranslation("layout");
|
||||
const [runningScript, setRunningScript] = useState<{
|
||||
path: string;
|
||||
name: string;
|
||||
mode?: "local" | "ssh";
|
||||
server?: any;
|
||||
} | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"scripts" | "downloaded" | "installed"
|
||||
>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const savedTab = localStorage.getItem("activeTab") as
|
||||
| "scripts"
|
||||
| "downloaded"
|
||||
| "installed";
|
||||
return savedTab || "scripts";
|
||||
}
|
||||
return 'scripts';
|
||||
return "scripts";
|
||||
});
|
||||
const [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
|
||||
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(undefined);
|
||||
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch data for script counts
|
||||
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
const { data: localScriptsData } = api.scripts.getAllDownloadedScripts.useQuery();
|
||||
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
|
||||
const { data: scriptCardsData } =
|
||||
api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
const { data: localScriptsData } =
|
||||
api.scripts.getAllDownloadedScripts.useQuery();
|
||||
const { data: installedScriptsData } =
|
||||
api.installedScripts.getAllInstalledScripts.useQuery();
|
||||
const { data: versionData } = api.version.getCurrentVersion.useQuery();
|
||||
|
||||
// Save active tab to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('activeTab', activeTab);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("activeTab", activeTab);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
@@ -50,9 +70,12 @@ export default function Home() {
|
||||
if (versionData?.success && versionData.version) {
|
||||
const currentVersion = versionData.version;
|
||||
const lastSeenVersion = getLastSeenVersion();
|
||||
|
||||
|
||||
// If we have a current version and either no last seen version or versions don't match
|
||||
if (currentVersion && (!lastSeenVersion || currentVersion !== lastSeenVersion)) {
|
||||
if (
|
||||
currentVersion &&
|
||||
(!lastSeenVersion || currentVersion !== lastSeenVersion)
|
||||
) {
|
||||
setHighlightVersion(currentVersion);
|
||||
setReleaseNotesOpen(true);
|
||||
}
|
||||
@@ -73,11 +96,11 @@ export default function Home() {
|
||||
const scriptCounts = {
|
||||
available: (() => {
|
||||
if (!scriptCardsData?.success) return 0;
|
||||
|
||||
|
||||
// Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx)
|
||||
const scriptMap = new Map<string, any>();
|
||||
|
||||
scriptCardsData.cards?.forEach(script => {
|
||||
|
||||
scriptCardsData.cards?.forEach((script) => {
|
||||
if (script?.name && script?.slug) {
|
||||
// Use slug as unique identifier, only keep first occurrence
|
||||
if (!scriptMap.has(script.slug)) {
|
||||
@@ -85,38 +108,40 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return scriptMap.size;
|
||||
})(),
|
||||
downloaded: (() => {
|
||||
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
|
||||
|
||||
|
||||
// First deduplicate GitHub scripts using Map by slug
|
||||
const scriptMap = new Map<string, any>();
|
||||
|
||||
scriptCardsData.cards?.forEach(script => {
|
||||
|
||||
scriptCardsData.cards?.forEach((script) => {
|
||||
if (script?.name && script?.slug) {
|
||||
if (!scriptMap.has(script.slug)) {
|
||||
scriptMap.set(script.slug, script);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const deduplicatedGithubScripts = Array.from(scriptMap.values());
|
||||
const localScripts = localScriptsData.scripts ?? [];
|
||||
|
||||
|
||||
// Count scripts that are both in deduplicated GitHub data and have local versions
|
||||
return deduplicatedGithubScripts.filter(script => {
|
||||
return deduplicatedGithubScripts.filter((script) => {
|
||||
if (!script?.name) return false;
|
||||
return localScripts.some(local => {
|
||||
return localScripts.some((local) => {
|
||||
if (!local?.name) return false;
|
||||
const localName = local.name.replace(/\.sh$/, '');
|
||||
return localName.toLowerCase() === script.name.toLowerCase() ||
|
||||
localName.toLowerCase() === (script.slug ?? '').toLowerCase();
|
||||
const localName = local.name.replace(/\.sh$/, "");
|
||||
return (
|
||||
localName.toLowerCase() === script.name.toLowerCase() ||
|
||||
localName.toLowerCase() === (script.slug ?? "").toLowerCase()
|
||||
);
|
||||
});
|
||||
}).length;
|
||||
})(),
|
||||
installed: installedScriptsData?.scripts?.length ?? 0
|
||||
installed: installedScriptsData?.scripts?.length ?? 0,
|
||||
};
|
||||
|
||||
const scrollToTerminal = () => {
|
||||
@@ -124,15 +149,20 @@ export default function Home() {
|
||||
// Get the element's position and scroll with a small offset for better mobile experience
|
||||
const elementTop = terminalRef.current.offsetTop;
|
||||
const offset = window.innerWidth < 768 ? 20 : 0; // Small offset on mobile
|
||||
|
||||
|
||||
window.scrollTo({
|
||||
top: elementTop - offset,
|
||||
behavior: 'smooth'
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => {
|
||||
const handleRunScript = (
|
||||
scriptPath: string,
|
||||
scriptName: string,
|
||||
mode?: "local" | "ssh",
|
||||
server?: any,
|
||||
) => {
|
||||
setRunningScript({ path: scriptPath, name: scriptName, mode, server });
|
||||
// Scroll to terminal after a short delay to ensure it's rendered
|
||||
setTimeout(scrollToTerminal, 100);
|
||||
@@ -143,21 +173,22 @@ export default function Home() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8">
|
||||
<main className="bg-background min-h-screen">
|
||||
<div className="container mx-auto px-2 py-4 sm:px-4 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6 sm:mb-8">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="mb-6 text-center sm:mb-8">
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div className="flex-1"></div>
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground flex items-center justify-center gap-2 sm:gap-3 flex-1">
|
||||
<span className="break-words">PVE Scripts Management</span>
|
||||
<h1 className="text-foreground flex flex-1 items-center justify-center gap-2 text-2xl font-bold sm:gap-3 sm:text-3xl lg:text-4xl">
|
||||
<span className="break-words">{t("title")}</span>
|
||||
</h1>
|
||||
<div className="flex-1 flex justify-end">
|
||||
<div className="flex flex-1 justify-end gap-2">
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
|
||||
Manage and execute Proxmox helper scripts locally with live output streaming
|
||||
<p className="text-muted-foreground mb-4 px-2 text-sm sm:text-base">
|
||||
{t("tagline")}
|
||||
</p>
|
||||
<div className="flex justify-center px-2">
|
||||
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
|
||||
@@ -166,7 +197,7 @@ export default function Home() {
|
||||
|
||||
{/* Controls */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
|
||||
<div className="bg-card border-border flex flex-col gap-4 rounded-lg border p-4 shadow-sm sm:flex-row sm:flex-wrap sm:items-center sm:p-6">
|
||||
<ServerSettingsButton />
|
||||
<SettingsButton />
|
||||
<ResyncButton />
|
||||
@@ -176,65 +207,75 @@ export default function Home() {
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="border-b border-border">
|
||||
<nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-1">
|
||||
<div className="border-border border-b">
|
||||
<nav className="-mb-px flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
onClick={() => setActiveTab('scripts')}
|
||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||
activeTab === 'scripts'
|
||||
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
||||
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
||||
}`}>
|
||||
onClick={() => setActiveTab("scripts")}
|
||||
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
|
||||
activeTab === "scripts"
|
||||
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
|
||||
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
|
||||
}`}
|
||||
>
|
||||
<Package className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Available Scripts</span>
|
||||
<span className="sm:hidden">Available</span>
|
||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||
<span className="hidden sm:inline">{t("tabs.available")}</span>
|
||||
<span className="sm:hidden">{t("tabs.availableShort")}</span>
|
||||
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
|
||||
{scriptCounts.available}
|
||||
</span>
|
||||
<ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" />
|
||||
<ContextualHelpIcon
|
||||
section="available-scripts"
|
||||
tooltip={t("help.availableTooltip")}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
onClick={() => setActiveTab('downloaded')}
|
||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||
activeTab === 'downloaded'
|
||||
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
||||
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
||||
}`}>
|
||||
onClick={() => setActiveTab("downloaded")}
|
||||
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
|
||||
activeTab === "downloaded"
|
||||
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
|
||||
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
|
||||
}`}
|
||||
>
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Downloaded Scripts</span>
|
||||
<span className="sm:hidden">Downloaded</span>
|
||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||
<span className="hidden sm:inline">{t("tabs.downloaded")}</span>
|
||||
<span className="sm:hidden">{t("tabs.downloadedShort")}</span>
|
||||
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
|
||||
{scriptCounts.downloaded}
|
||||
</span>
|
||||
<ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" />
|
||||
<ContextualHelpIcon
|
||||
section="downloaded-scripts"
|
||||
tooltip={t("help.downloadedTooltip")}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
onClick={() => setActiveTab('installed')}
|
||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||
activeTab === 'installed'
|
||||
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
||||
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
||||
}`}>
|
||||
onClick={() => setActiveTab("installed")}
|
||||
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
|
||||
activeTab === "installed"
|
||||
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
|
||||
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Installed Scripts</span>
|
||||
<span className="sm:hidden">Installed</span>
|
||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||
<span className="hidden sm:inline">{t("tabs.installed")}</span>
|
||||
<span className="sm:hidden">{t("tabs.installedShort")}</span>
|
||||
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
|
||||
{scriptCounts.installed}
|
||||
</span>
|
||||
<ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
|
||||
<ContextualHelpIcon
|
||||
section="installed-scripts"
|
||||
tooltip={t("help.installedTooltip")}
|
||||
/>
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Running Script Terminal */}
|
||||
{runningScript && (
|
||||
<div ref={terminalRef} className="mb-8">
|
||||
@@ -248,17 +289,15 @@ export default function Home() {
|
||||
)}
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'scripts' && (
|
||||
{activeTab === "scripts" && (
|
||||
<ScriptsGrid onInstallScript={handleRunScript} />
|
||||
)}
|
||||
|
||||
{activeTab === 'downloaded' && (
|
||||
|
||||
{activeTab === "downloaded" && (
|
||||
<DownloadedScriptsTab onInstallScript={handleRunScript} />
|
||||
)}
|
||||
|
||||
{activeTab === 'installed' && (
|
||||
<InstalledScriptsTab />
|
||||
)}
|
||||
|
||||
{activeTab === "installed" && <InstalledScriptsTab />}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
142
src/lib/i18n/LanguageProvider.tsx
Normal file
142
src/lib/i18n/LanguageProvider.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { defaultLocale, isLocale, locales, type Locale } from "./config";
|
||||
import { createTranslator, type TranslateOptions } from "./translator";
|
||||
|
||||
export interface LanguageContextValue {
|
||||
locale: Locale;
|
||||
availableLocales: readonly Locale[];
|
||||
setLocale: (nextLocale: Locale) => void;
|
||||
t: (key: string, options?: TranslateOptions) => string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "pve-locale";
|
||||
const COOKIE_KEY = "pve-locale";
|
||||
|
||||
export const LanguageContext = createContext<LanguageContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface LanguageProviderProps {
|
||||
children: ReactNode;
|
||||
initialLocale?: Locale;
|
||||
}
|
||||
|
||||
function getInitialLocale(initialLocale?: Locale): Locale {
|
||||
if (initialLocale && isLocale(initialLocale)) {
|
||||
return initialLocale;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored && isLocale(stored)) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
const browserLocale = window.navigator.language?.slice(0, 2).toLowerCase();
|
||||
if (isLocale(browserLocale)) {
|
||||
return browserLocale;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve initial locale", error);
|
||||
}
|
||||
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
export function LanguageProvider({
|
||||
children,
|
||||
initialLocale,
|
||||
}: LanguageProviderProps) {
|
||||
const [locale, setLocaleState] = useState<Locale>(() =>
|
||||
getInitialLocale(initialLocale),
|
||||
);
|
||||
const hasHydrated = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.lang = locale;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, locale);
|
||||
document.cookie = `${COOKIE_KEY}=${locale}; path=/; max-age=31536000`;
|
||||
} catch (error) {
|
||||
console.error("Failed to persist locale", error);
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasHydrated.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasHydrated.current = true;
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored && isLocale(stored) && stored !== locale) {
|
||||
setLocaleState(stored);
|
||||
return;
|
||||
}
|
||||
|
||||
const browserLocale = window.navigator.language
|
||||
?.slice(0, 2)
|
||||
.toLowerCase();
|
||||
if (isLocale(browserLocale) && browserLocale !== locale) {
|
||||
setLocaleState(browserLocale);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to hydrate locale from client settings", error);
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
const setLocale = useCallback((nextLocale: Locale) => {
|
||||
if (!isLocale(nextLocale)) {
|
||||
return;
|
||||
}
|
||||
setLocaleState(nextLocale);
|
||||
}, []);
|
||||
|
||||
const translator = useMemo(() => createTranslator(locale), [locale]);
|
||||
|
||||
const value = useMemo<LanguageContextValue>(
|
||||
() => ({
|
||||
locale,
|
||||
availableLocales: locales,
|
||||
setLocale,
|
||||
t: (key: string, options?: TranslateOptions) => translator(key, options),
|
||||
}),
|
||||
[locale, setLocale, translator],
|
||||
);
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={value}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLanguageContext(): LanguageContextValue {
|
||||
const context = useContext(LanguageContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useLanguageContext must be used within a LanguageProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
9
src/lib/i18n/config.ts
Normal file
9
src/lib/i18n/config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const locales = ['en', 'de'] as const;
|
||||
|
||||
export type Locale = (typeof locales)[number];
|
||||
|
||||
export const defaultLocale: Locale = 'en';
|
||||
|
||||
export function isLocale(value: unknown): value is Locale {
|
||||
return typeof value === 'string' && (locales as readonly string[]).includes(value);
|
||||
}
|
||||
266
src/lib/i18n/messages/de.ts
Normal file
266
src/lib/i18n/messages/de.ts
Normal 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
266
src/lib/i18n/messages/en.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
9
src/lib/i18n/messages/index.ts
Normal file
9
src/lib/i18n/messages/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { Locale } from '../config';
|
||||
import type { NestedMessages } from './types';
|
||||
import { enMessages } from './en';
|
||||
import { deMessages } from './de';
|
||||
|
||||
export const messages: Record<Locale, NestedMessages> = {
|
||||
en: enMessages,
|
||||
de: deMessages,
|
||||
};
|
||||
3
src/lib/i18n/messages/types.ts
Normal file
3
src/lib/i18n/messages/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type NestedMessages = {
|
||||
[key: string]: string | NestedMessages;
|
||||
};
|
||||
76
src/lib/i18n/translator.ts
Normal file
76
src/lib/i18n/translator.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { defaultLocale, type Locale, isLocale } from './config';
|
||||
import { messages } from './messages';
|
||||
import type { NestedMessages } from './messages/types';
|
||||
|
||||
export type TranslateValues = Record<string, string | number>;
|
||||
|
||||
export interface TranslateOptions {
|
||||
fallback?: string;
|
||||
values?: TranslateValues;
|
||||
}
|
||||
|
||||
function getNestedMessage(tree: NestedMessages | string | undefined, segments: string[]): string | undefined {
|
||||
if (segments.length === 0) {
|
||||
return typeof tree === 'string' ? tree : undefined;
|
||||
}
|
||||
|
||||
if (!tree || typeof tree === 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [current, ...rest] = segments;
|
||||
if (!current) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const next: NestedMessages | string | undefined = tree[current];
|
||||
return getNestedMessage(next, rest);
|
||||
}
|
||||
|
||||
function formatMessage(template: string, values?: TranslateValues): string {
|
||||
if (!values) {
|
||||
return template;
|
||||
}
|
||||
|
||||
return template.replace(/\{(.*?)\}/g, (match, token: string) => {
|
||||
const value = values[token];
|
||||
if (value === undefined || value === null) {
|
||||
return match;
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveMessage(locale: Locale, key: string): string | undefined {
|
||||
const dictionary = messages[locale];
|
||||
if (!dictionary) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const segments = key.split('.').filter(Boolean);
|
||||
return getNestedMessage(dictionary, segments);
|
||||
}
|
||||
|
||||
export function createTranslator(locale: Locale) {
|
||||
const normalizedLocale: Locale = isLocale(locale) ? locale : defaultLocale;
|
||||
|
||||
return (key: string, options?: TranslateOptions): string => {
|
||||
const fallbackLocales: Locale[] = [normalizedLocale];
|
||||
if (normalizedLocale !== defaultLocale) {
|
||||
fallbackLocales.push(defaultLocale);
|
||||
}
|
||||
|
||||
for (const currentLocale of fallbackLocales) {
|
||||
const message = resolveMessage(currentLocale, key);
|
||||
if (typeof message === 'string') {
|
||||
return formatMessage(message, options?.values);
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.fallback) {
|
||||
return formatMessage(options.fallback, options.values);
|
||||
}
|
||||
|
||||
return key;
|
||||
};
|
||||
}
|
||||
31
src/lib/i18n/useTranslation.ts
Normal file
31
src/lib/i18n/useTranslation.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { type LanguageContextValue, useLanguageContext } from './LanguageProvider';
|
||||
import type { TranslateOptions } from './translator';
|
||||
|
||||
export interface UseTranslationResult {
|
||||
locale: LanguageContextValue['locale'];
|
||||
availableLocales: LanguageContextValue['availableLocales'];
|
||||
setLocale: LanguageContextValue['setLocale'];
|
||||
t: (key: string, options?: TranslateOptions) => string;
|
||||
}
|
||||
|
||||
export function useTranslation(namespace?: string): UseTranslationResult {
|
||||
const { t: translate, locale, setLocale, availableLocales } = useLanguageContext();
|
||||
|
||||
const scopedTranslate = useCallback(
|
||||
(key: string, options?: TranslateOptions) => {
|
||||
const namespacedKey = namespace ? `${namespace}.${key}` : key;
|
||||
return translate(namespacedKey, options);
|
||||
},
|
||||
[namespace, translate],
|
||||
);
|
||||
|
||||
return {
|
||||
locale,
|
||||
availableLocales,
|
||||
setLocale,
|
||||
t: scopedTranslate,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user