Files
envHelper/src/App.tsx
MrSphay 03407feeb9
All checks were successful
Build Windows App / build-windows (push) Successful in 24m1s
Make window controls fire on pointer down
2026-05-01 20:35:35 +02:00

573 lines
18 KiB
TypeScript

import {
Check,
Clipboard,
FileDown,
FileInput,
Languages,
Maximize2,
Minus,
Plus,
RefreshCcw,
Settings,
ShieldCheck,
SunMoon,
Trash2,
X
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { PointerEvent, ReactNode } from "react";
import packageInfo from "../package.json";
import { transformEnv } from "./env";
import type { EnvDefault } from "./env";
type ThemeMode = "system" | "light" | "dark";
type Language = "de" | "en" | "es" | "fr" | "nl";
const sampleEnv = `APP_IMAGE=git.wilkensxl.de/mrsphay/hilden-directory-gateway:latest
APP_PORT=3000
NODE_ENV=production
PUBLIC_BASE_URL=https://gateway.example.local
TRUSTED_IFRAME_ANCESTORS=https://www.hilden.de
DATABASE_URL=postgresql://hilden_app:CHANGE_ME_POSTGRES_PASSWORD@postgres:5432/hilden_directory
POSTGRES_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD
SESSION_SECRET=CHANGE_ME_AT_LEAST_32_RANDOM_CHARACTERS
ENCRYPTION_KEY_BASE64=CHANGE_ME_32_RANDOM_BYTES_AS_BASE64
BOOTSTRAP_ADMIN_EMAIL=admin@example.local
BOOTSTRAP_ADMIN_PASSWORD=CHANGE_ME_LONG_INITIAL_ADMIN_PASSWORD`;
const initialDefaults: EnvDefault[] = [{ key: "BOOTSTRAP_ADMIN_EMAIL", value: "admin@example.local" }];
const languageNames: Record<Language, string> = {
de: "Deutsch",
en: "English",
es: "Español",
fr: "Français",
nl: "Nederlands"
};
const translations = {
de: {
appSubtitle: "Lokaler Helfer für .env Vorlagen",
loadFile: "Datei laden",
saveFile: ".env speichern",
settings: "Einstellungen",
input: "Input",
output: "Output",
inputHint: "Datei laden oder Text direkt bearbeiten",
outputHint: "Generierte .env, bereit zum Kopieren",
copy: "Kopieren",
copied: "Kopiert",
regenerate: "Neu erzeugen",
sample: "Beispiel laden",
hits: "Fundstellen",
values: "Werte",
defaultsApplied: "Defaults",
source: "Quelle",
text: "Text",
defaults: "Standardwerte",
defaultsHint: "Diese Werte überschreiben passende Keys im Output.",
addDefault: "Standardwert hinzufügen",
key: "Key",
value: "Wert",
detected: "Erkannte Anforderungen",
detectedHint: "Placeholder, betroffene Keys und erzeugtes Format.",
placeholder: "Placeholder",
keys: "Keys",
format: "Format",
noPlaceholders: "Keine CHANGE_ME Platzhalter gefunden.",
language: "Sprache",
theme: "Darstellung",
system: "System",
light: "Hell",
dark: "Dunkel",
version: "Version",
author: "Autor",
repository: "Repository"
},
en: {
appSubtitle: "Local helper for .env templates",
loadFile: "Load file",
saveFile: "Save .env",
settings: "Settings",
input: "Input",
output: "Output",
inputHint: "Load a file or edit text directly",
outputHint: "Generated .env, ready to copy",
copy: "Copy",
copied: "Copied",
regenerate: "Regenerate",
sample: "Load sample",
hits: "Matches",
values: "Values",
defaultsApplied: "Defaults",
source: "Source",
text: "Text",
defaults: "Defaults",
defaultsHint: "These values override matching keys in the output.",
addDefault: "Add default",
key: "Key",
value: "Value",
detected: "Detected requirements",
detectedHint: "Placeholder, affected keys, and generated format.",
placeholder: "Placeholder",
keys: "Keys",
format: "Format",
noPlaceholders: "No CHANGE_ME placeholders found.",
language: "Language",
theme: "Appearance",
system: "System",
light: "Light",
dark: "Dark",
version: "Version",
author: "Author",
repository: "Repository"
},
es: {
appSubtitle: "Ayudante local para plantillas .env",
loadFile: "Cargar archivo",
saveFile: "Guardar .env",
settings: "Ajustes",
input: "Entrada",
output: "Salida",
inputHint: "Carga un archivo o edita el texto",
outputHint: ".env generado, listo para copiar",
copy: "Copiar",
copied: "Copiado",
regenerate: "Regenerar",
sample: "Cargar ejemplo",
hits: "Coincidencias",
values: "Valores",
defaultsApplied: "Defaults",
source: "Origen",
text: "Texto",
defaults: "Valores predeterminados",
defaultsHint: "Estos valores sobrescriben claves iguales en la salida.",
addDefault: "Añadir valor",
key: "Clave",
value: "Valor",
detected: "Requisitos detectados",
detectedHint: "Placeholder, claves afectadas y formato generado.",
placeholder: "Placeholder",
keys: "Claves",
format: "Formato",
noPlaceholders: "No se encontraron placeholders CHANGE_ME.",
language: "Idioma",
theme: "Apariencia",
system: "Sistema",
light: "Claro",
dark: "Oscuro",
version: "Versión",
author: "Autor",
repository: "Repositorio"
},
fr: {
appSubtitle: "Assistant local pour modèles .env",
loadFile: "Charger",
saveFile: "Enregistrer .env",
settings: "Réglages",
input: "Entrée",
output: "Sortie",
inputHint: "Chargez un fichier ou modifiez le texte",
outputHint: ".env généré, prêt à copier",
copy: "Copier",
copied: "Copié",
regenerate: "Régénérer",
sample: "Charger exemple",
hits: "Occurrences",
values: "Valeurs",
defaultsApplied: "Défauts",
source: "Source",
text: "Texte",
defaults: "Valeurs par défaut",
defaultsHint: "Ces valeurs remplacent les clés correspondantes dans la sortie.",
addDefault: "Ajouter",
key: "Clé",
value: "Valeur",
detected: "Exigences détectées",
detectedHint: "Placeholder, clés concernées et format généré.",
placeholder: "Placeholder",
keys: "Clés",
format: "Format",
noPlaceholders: "Aucun placeholder CHANGE_ME trouvé.",
language: "Langue",
theme: "Apparence",
system: "Système",
light: "Clair",
dark: "Sombre",
version: "Version",
author: "Auteur",
repository: "Dépôt"
},
nl: {
appSubtitle: "Lokale helper voor .env sjablonen",
loadFile: "Bestand laden",
saveFile: ".env opslaan",
settings: "Instellingen",
input: "Input",
output: "Output",
inputHint: "Laad een bestand of bewerk tekst direct",
outputHint: "Gegenereerde .env, klaar om te kopiëren",
copy: "Kopiëren",
copied: "Gekopieerd",
regenerate: "Opnieuw maken",
sample: "Voorbeeld laden",
hits: "Treffers",
values: "Waarden",
defaultsApplied: "Defaults",
source: "Bron",
text: "Tekst",
defaults: "Standaardwaarden",
defaultsHint: "Deze waarden overschrijven gelijke keys in de output.",
addDefault: "Waarde toevoegen",
key: "Key",
value: "Waarde",
detected: "Gedetecteerde eisen",
detectedHint: "Placeholder, betrokken keys en gegenereerd formaat.",
placeholder: "Placeholder",
keys: "Keys",
format: "Formaat",
noPlaceholders: "Geen CHANGE_ME placeholders gevonden.",
language: "Taal",
theme: "Weergave",
system: "Systeem",
light: "Licht",
dark: "Donker",
version: "Versie",
author: "Auteur",
repository: "Repository"
}
} satisfies Record<Language, Record<string, string>>;
const themeLabels: ThemeMode[] = ["system", "light", "dark"];
function detectLanguage(): Language {
const stored = localStorage.getItem("envhelper-language") as Language | null;
if (stored && stored in languageNames) {
return stored;
}
const browserLanguage = navigator.language.slice(0, 2) as Language;
return browserLanguage in languageNames ? browserLanguage : "de";
}
function readDefaults(): EnvDefault[] {
const stored = localStorage.getItem("envhelper-defaults");
if (!stored) {
return initialDefaults;
}
try {
const parsed = JSON.parse(stored) as EnvDefault[];
return Array.isArray(parsed) ? parsed : initialDefaults;
} catch {
return initialDefaults;
}
}
function readTheme(): ThemeMode {
const stored = localStorage.getItem("envhelper-theme") as ThemeMode | null;
return stored && themeLabels.includes(stored) ? stored : "system";
}
export default function App() {
const [input, setInput] = useState(sampleEnv);
const [loadedPath, setLoadedPath] = useState<string | null>(null);
const [copyLabel, setCopyLabel] = useState<"copy" | "copied">("copy");
const [generation, setGeneration] = useState(0);
const [settingsOpen, setSettingsOpen] = useState(false);
const [themeMode, setThemeMode] = useState<ThemeMode>(readTheme);
const [language, setLanguage] = useState<Language>(detectLanguage);
const [defaults, setDefaults] = useState<EnvDefault[]>(readDefaults);
const t = translations[language];
const result = useMemo(() => transformEnv(input, defaults), [input, defaults, generation]);
useEffect(() => {
document.documentElement.dataset.theme = themeMode;
localStorage.setItem("envhelper-theme", themeMode);
}, [themeMode]);
useEffect(() => {
localStorage.setItem("envhelper-language", language);
}, [language]);
useEffect(() => {
localStorage.setItem("envhelper-defaults", JSON.stringify(defaults));
}, [defaults]);
async function openFile() {
const file = await window.envHelper?.openFile();
if (file) {
setInput(file.content);
setLoadedPath(file.path);
}
}
async function saveFile() {
await window.envHelper?.saveFile(result.output);
}
async function copyOutput() {
await navigator.clipboard.writeText(result.output);
setCopyLabel("copied");
window.setTimeout(() => setCopyLabel("copy"), 1400);
}
function updateDefault(index: number, field: keyof EnvDefault, value: string) {
setDefaults((current) => current.map((entry, entryIndex) => (entryIndex === index ? { ...entry, [field]: value } : entry)));
}
function addDefault() {
setDefaults((current) => [...current, { key: "", value: "" }]);
}
function removeDefault(index: number) {
setDefaults((current) => current.filter((_, entryIndex) => entryIndex !== index));
}
function runWindowControl(event: PointerEvent<HTMLButtonElement>, action?: () => void) {
event.preventDefault();
event.stopPropagation();
action?.();
}
return (
<main className="appShell">
<section className="titlebar">
<div className="titlebarDrag">
<div className="titlebarBrand">
<div className="titlebarMark">EH</div>
<span>EnvHelper</span>
</div>
</div>
<div className="windowControls">
<button
aria-label="Minimize"
onPointerDown={(event) => runWindowControl(event, window.envHelper?.minimizeWindow)}
title="Minimize"
type="button"
>
<Minus size={15} />
</button>
<button
aria-label="Maximize"
onPointerDown={(event) => runWindowControl(event, window.envHelper?.toggleMaximizeWindow)}
title="Maximize"
type="button"
>
<Maximize2 size={14} />
</button>
<button
aria-label="Close"
className="closeButton"
onPointerDown={(event) => runWindowControl(event, window.envHelper?.closeWindow)}
title="Close"
type="button"
>
<X size={15} />
</button>
</div>
</section>
<header className="toolbar">
<div className="brand">
<div className="brandMark">EH</div>
<div>
<h1>EnvHelper</h1>
<p>{t.appSubtitle}</p>
</div>
</div>
<div className="toolbarActions">
<button className="secondary" onClick={openFile} type="button">
<FileInput size={17} /> {t.loadFile}
</button>
<button className="secondary" onClick={saveFile} type="button" disabled={!result.output}>
<FileDown size={17} /> {t.saveFile}
</button>
<div className="segmented" aria-label={t.theme}>
{themeLabels.map((mode) => (
<button
className={themeMode === mode ? "active" : ""}
key={mode}
onClick={() => setThemeMode(mode)}
type="button"
title={t[mode]}
>
{mode === "system" ? <SunMoon size={15} /> : t[mode]}
</button>
))}
</div>
<button className="iconButton" onClick={() => setSettingsOpen(true)} title={t.settings} type="button">
<Settings size={18} />
</button>
</div>
</header>
<section className="statusStrip">
<Stat label={t.hits} value={String(result.changedCount)} />
<Stat label={t.values} value={String(result.replacements.length)} />
<Stat label={t.defaultsApplied} value={String(result.defaultedKeys.length)} />
<Stat label={t.source} value={loadedPath ? "Datei" : t.text} />
</section>
<section className="workbench">
<EditorPanel
actions={
<button className="iconButton" onClick={() => setInput(sampleEnv)} title={t.sample} type="button">
<RefreshCcw size={17} />
</button>
}
hint={loadedPath ?? t.inputHint}
title={t.input}
>
<textarea spellCheck={false} value={input} onChange={(event) => setInput(event.target.value)} aria-label="Env input" />
</EditorPanel>
<EditorPanel
actions={
<>
<button className="iconButton" onClick={() => setGeneration((current) => current + 1)} title={t.regenerate} type="button">
<RefreshCcw size={17} />
</button>
<button className="primary" onClick={copyOutput} type="button">
{copyLabel === "copied" ? <Check size={17} /> : <Clipboard size={17} />} {copyLabel === "copied" ? t.copied : t.copy}
</button>
</>
}
hint={t.outputHint}
title={t.output}
>
<textarea spellCheck={false} value={result.output} readOnly aria-label="Env output" />
</EditorPanel>
</section>
<section className="lowerGrid">
<section className="dataPanel">
<PanelHeading icon={<Plus size={18} />} title={t.defaults} hint={t.defaultsHint} />
<div className="defaultsList">
{defaults.map((entry, index) => (
<div className="defaultRow" key={index}>
<input
aria-label={t.key}
placeholder={t.key}
value={entry.key}
onChange={(event) => updateDefault(index, "key", event.target.value)}
/>
<input
aria-label={t.value}
placeholder={t.value}
value={entry.value}
onChange={(event) => updateDefault(index, "value", event.target.value)}
/>
<button className="iconButton subtle" onClick={() => removeDefault(index)} title="Remove" type="button">
<Trash2 size={16} />
</button>
</div>
))}
</div>
<button className="secondary fullWidth" onClick={addDefault} type="button">
<Plus size={17} /> {t.addDefault}
</button>
</section>
<section className="dataPanel">
<PanelHeading icon={<ShieldCheck size={18} />} title={t.detected} hint={t.detectedHint} />
{result.replacements.length === 0 ? (
<div className="empty">{t.noPlaceholders}</div>
) : (
<div className="requirementsTable">
<div className="tableHeader">
<span>{t.placeholder}</span>
<span>{t.keys}</span>
<span>{t.format}</span>
</div>
{result.replacements.map((replacement) => (
<div className="tableRow" key={replacement.placeholder}>
<code>{replacement.placeholder}</code>
<span>{replacement.keys.join(", ")}</span>
<strong>{replacement.format}</strong>
</div>
))}
</div>
)}
</section>
</section>
{settingsOpen ? (
<aside className="settingsOverlay" aria-label={t.settings}>
<div className="settingsPanel">
<div className="settingsHeader">
<div>
<h2>{t.settings}</h2>
<p>{t.appSubtitle}</p>
</div>
<button className="iconButton" onClick={() => setSettingsOpen(false)} title="Close" type="button">
<X size={18} />
</button>
</div>
<div className="settingsGroup">
<label>
<Languages size={18} /> {t.language}
</label>
<div className="languageGrid">
{(Object.keys(languageNames) as Language[]).map((code) => (
<button className={language === code ? "selected" : ""} key={code} onClick={() => setLanguage(code)} type="button">
{languageNames[code]}
</button>
))}
</div>
</div>
<div className="settingsMeta">
<span>{t.version}: {packageInfo.version}</span>
<span>{t.author}: {packageInfo.author}</span>
<span>{t.repository}: MrSphay/envHelper</span>
</div>
</div>
</aside>
) : null}
</main>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="statItem">
<span>{value}</span>
<p>{label}</p>
</div>
);
}
function EditorPanel({ actions, children, hint, title }: { actions: ReactNode; children: ReactNode; hint: string; title: string }) {
return (
<section className="editorPanel">
<div className="panelHeader">
<div>
<h2>{title}</h2>
<p>{hint}</p>
</div>
<div className="buttonRow">{actions}</div>
</div>
{children}
</section>
);
}
function PanelHeading({ hint, icon, title }: { hint: string; icon: ReactNode; title: string }) {
return (
<div className="panelHeading">
<div>
<h2>{title}</h2>
<p>{hint}</p>
</div>
{icon}
</div>
);
}