Improve desktop UI settings defaults and env inference
All checks were successful
Build Windows App / build-windows (push) Successful in 16m23s
All checks were successful
Build Windows App / build-windows (push) Successful in 16m23s
This commit is contained in:
104
README.md
104
README.md
@@ -1,14 +1,88 @@
|
||||
# EnvHelper
|
||||
|
||||
EnvHelper ist eine Windows-Desktop-App zum Ersetzen von `CHANGE_ME` Platzhaltern in `.env` Dateien.
|
||||
**EnvHelper** ist eine lokale Windows-Desktop-App zum Ausfüllen von `.env` Vorlagen. Sie erkennt `CHANGE_ME` Platzhalter, erzeugt passende Werte im erwarteten Format und gibt eine fertige `.env` aus.
|
||||
|
||||
## Funktionen
|
||||
```text
|
||||
Status: Desktop App
|
||||
Plattform: Windows
|
||||
Stack: Electron, React, Vite, TypeScript
|
||||
Build: Gitea Actions Runner
|
||||
```
|
||||
|
||||
## Highlights
|
||||
|
||||
- `.env` Datei laden oder reinen Text einfügen
|
||||
- `CHANGE_ME...` Platzhalter erkennen
|
||||
- passende Werte anhand von Variablenname und Platzhalter ableiten
|
||||
- gleiche Platzhalter konsistent mit demselben Wert ersetzen
|
||||
- neue `.env` als Text kopieren oder speichern
|
||||
- `CHANGE_ME...` Platzhalter automatisch erkennen
|
||||
- Passwörter, Secrets, Base64-Keys, UUIDs, Ports, URLs und E-Mails heuristisch erzeugen
|
||||
- Gleiche Placeholder konsistent mit demselben Wert ersetzen
|
||||
- Standardwerte pro Key definieren und im Output überschreiben lassen
|
||||
- Hell/Dunkel/System Theme
|
||||
- Einstellungen mit Sprache, Version und Projektinfos
|
||||
- Ausgabe kopieren oder als neue `.env` speichern
|
||||
|
||||
## Vorschau
|
||||
|
||||
Screenshots können später im Repository ergänzt werden.
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ EnvHelper Datei laden .env speichern Theme Settings │
|
||||
├─────────────────────────────┬───────────────────────────────┤
|
||||
│ Input │ Output │
|
||||
│ DATABASE_URL=...CHANGE_ME │ DATABASE_URL=...generated... │
|
||||
├─────────────────────────────┴───────────────────────────────┤
|
||||
│ Standardwerte Erkannte Anforderungen │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Beispiel
|
||||
|
||||
Eingabe:
|
||||
|
||||
```env
|
||||
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
|
||||
```
|
||||
|
||||
Ausgabe:
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgresql://hilden_app:generated-postgres-password@postgres:5432/hilden_directory
|
||||
POSTGRES_PASSWORD=generated-postgres-password
|
||||
SESSION_SECRET=generated-url-safe-secret
|
||||
ENCRYPTION_KEY_BASE64=generated-base64-key
|
||||
BOOTSTRAP_ADMIN_EMAIL=admin@example.local
|
||||
```
|
||||
|
||||
Die tatsächlichen Werte werden zufällig lokal erzeugt.
|
||||
|
||||
## Standardwerte
|
||||
|
||||
Standardwerte werden in einer eigenen Sektion gepflegt. Wenn ein Key dort eingetragen ist, überschreibt der Wert den entsprechenden Output-Key.
|
||||
|
||||
Beispiel:
|
||||
|
||||
```text
|
||||
BOOTSTRAP_ADMIN_EMAIL = admin@example.local
|
||||
```
|
||||
|
||||
Dann wird im Output immer dieser Wert gesetzt, auch wenn die Vorlage bereits eine andere E-Mail enthält.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
EnvHelper arbeitet lokal. Die Werte werden im Renderer mit Web-Crypto erzeugt und nicht an externe Dienste gesendet. Die App ist ein Helfer für Vorlagen, ersetzt aber keine zentrale Secret-Verwaltung für produktive Infrastruktur.
|
||||
|
||||
## Download und Artefakte
|
||||
|
||||
Der Windows-Build erzeugt zwei Dateien:
|
||||
|
||||
- `EnvHelper-0.1.0-setup-x64.exe`
|
||||
- `EnvHelper-0.1.0-portable-x64.exe`
|
||||
|
||||
Die Dateien werden vom Gitea Runner als Actions-Artefakt und als Generic Package veröffentlicht.
|
||||
|
||||
## Entwicklung
|
||||
|
||||
@@ -17,7 +91,7 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
In einem zweiten Terminal:
|
||||
In einem zweiten Terminal kann der Electron-Build geprüft werden:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
@@ -25,10 +99,22 @@ npm run build
|
||||
|
||||
## Windows Build
|
||||
|
||||
Der Windows-Build läuft über Gitea Actions:
|
||||
Der Windows-Build läuft über Gitea Actions und nutzt den Linux Runner mit Wine:
|
||||
|
||||
```bash
|
||||
npm run dist:win
|
||||
```
|
||||
|
||||
Die erzeugten Dateien liegen im Runner unter `release/` und werden als Workflow-Artefakt hochgeladen.
|
||||
Workflow:
|
||||
|
||||
```text
|
||||
.gitea/workflows/build-windows.yml
|
||||
```
|
||||
|
||||
Der Runner installiert Node.js, Wine, baut die App mit Electron Builder und lädt die fertigen `.exe` Artefakte hoch.
|
||||
|
||||
## Projektinfos
|
||||
|
||||
- Autor: `MrSphay`
|
||||
- Repository: `MrSphay/envHelper`
|
||||
- App-ID: `de.wilkensxl.envhelper`
|
||||
|
||||
534
src/App.tsx
534
src/App.tsx
@@ -1,6 +1,25 @@
|
||||
import { Check, Clipboard, FileDown, FileInput, RefreshCcw, ShieldCheck, Sparkles } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
Clipboard,
|
||||
FileDown,
|
||||
FileInput,
|
||||
Languages,
|
||||
Plus,
|
||||
RefreshCcw,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
SunMoon,
|
||||
Trash2,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { 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
|
||||
@@ -18,12 +37,264 @@ 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("Kopieren");
|
||||
const [copyLabel, setCopyLabel] = useState<"copy" | "copied">("copy");
|
||||
const [generation, setGeneration] = useState(0);
|
||||
const result = useMemo(() => transformEnv(input), [input, generation]);
|
||||
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();
|
||||
@@ -39,112 +310,219 @@ export default function App() {
|
||||
|
||||
async function copyOutput() {
|
||||
await navigator.clipboard.writeText(result.output);
|
||||
setCopyLabel("Kopiert");
|
||||
window.setTimeout(() => setCopyLabel("Kopieren"), 1400);
|
||||
setCopyLabel("copied");
|
||||
window.setTimeout(() => setCopyLabel("copy"), 1400);
|
||||
}
|
||||
|
||||
function regenerate() {
|
||||
setGeneration((current) => current + 1);
|
||||
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));
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<section className="hero">
|
||||
<main className="appShell">
|
||||
<header className="toolbar">
|
||||
<div className="brand">
|
||||
<div className="brandMark">EH</div>
|
||||
<div>
|
||||
<p className="eyebrow"><Sparkles size={16} /> EnvHelper</p>
|
||||
<h1>.env Platzhalter sauber ersetzen</h1>
|
||||
<p className="subline">
|
||||
Erkennt CHANGE_ME Werte, erzeugt passende Secrets im richtigen Format und hält gleiche Platzhalter synchron.
|
||||
</p>
|
||||
<h1>EnvHelper</h1>
|
||||
<p>{t.appSubtitle}</p>
|
||||
</div>
|
||||
<div className="heroActions">
|
||||
</div>
|
||||
|
||||
<div className="toolbarActions">
|
||||
<button className="secondary" onClick={openFile} type="button">
|
||||
<FileInput size={18} /> Datei laden
|
||||
<FileInput size={17} /> {t.loadFile}
|
||||
</button>
|
||||
<button className="secondary" onClick={saveFile} type="button" disabled={!result.output}>
|
||||
<FileDown size={18} /> .env speichern
|
||||
<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="stats">
|
||||
<div>
|
||||
<span>{result.changedCount}</span>
|
||||
<p>Fundstellen</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>{result.replacements.length}</span>
|
||||
<p>eindeutige Werte</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>{loadedPath ? "Datei" : "Text"}</span>
|
||||
<p>Quelle</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="workspace">
|
||||
<div className="panel">
|
||||
<div className="panelHeader">
|
||||
<div>
|
||||
<h2>Input</h2>
|
||||
<p>{loadedPath ?? "Direkt einfügen oder Beispieldaten ersetzen"}</p>
|
||||
</div>
|
||||
<button className="iconButton" onClick={() => setInput(sampleEnv)} title="Beispiel laden" type="button">
|
||||
<RefreshCcw size={18} />
|
||||
<section className="workbench">
|
||||
<EditorPanel
|
||||
actions={
|
||||
<button className="iconButton" onClick={() => setInput(sampleEnv)} title={t.sample} type="button">
|
||||
<RefreshCcw size={17} />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
spellCheck={false}
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
aria-label="Env input"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
hint={loadedPath ?? t.inputHint}
|
||||
title={t.input}
|
||||
>
|
||||
<textarea spellCheck={false} value={input} onChange={(event) => setInput(event.target.value)} aria-label="Env input" />
|
||||
</EditorPanel>
|
||||
|
||||
<div className="panel">
|
||||
<div className="panelHeader">
|
||||
<div>
|
||||
<h2>Output</h2>
|
||||
<p>Bereit für Deployment oder Secret Store</p>
|
||||
</div>
|
||||
<div className="buttonRow">
|
||||
<button className="iconButton" onClick={regenerate} title="Neu erzeugen" type="button">
|
||||
<RefreshCcw size={18} />
|
||||
<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 === "Kopiert" ? <Check size={18} /> : <Clipboard size={18} />} {copyLabel}
|
||||
{copyLabel === "copied" ? <Check size={17} /> : <Clipboard size={17} />} {copyLabel === "copied" ? t.copied : t.copy}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
hint={t.outputHint}
|
||||
title={t.output}
|
||||
>
|
||||
<textarea spellCheck={false} value={result.output} readOnly aria-label="Env output" />
|
||||
</div>
|
||||
</EditorPanel>
|
||||
</section>
|
||||
|
||||
<section className="replacementPanel">
|
||||
<div className="panelHeader compact">
|
||||
<div>
|
||||
<h2>Erkannte Anforderungen</h2>
|
||||
<p>Formate werden aus Variablennamen und Platzhaltertext abgeleitet.</p>
|
||||
<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>
|
||||
<ShieldCheck size={22} />
|
||||
))}
|
||||
</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">Keine CHANGE_ME Platzhalter gefunden.</div>
|
||||
<div className="empty">{t.noPlaceholders}</div>
|
||||
) : (
|
||||
<div className="replacementGrid">
|
||||
{result.replacements.map((replacement) => (
|
||||
<article className="replacement" key={replacement.placeholder}>
|
||||
<div>
|
||||
<strong>{replacement.placeholder}</strong>
|
||||
<p>{replacement.keys.join(", ")}</p>
|
||||
<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>
|
||||
<span>{replacement.format}</span>
|
||||
</article>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
114
src/env.ts
114
src/env.ts
@@ -5,10 +5,16 @@ export interface Replacement {
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
export interface EnvDefault {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface TransformResult {
|
||||
output: string;
|
||||
replacements: Replacement[];
|
||||
changedCount: number;
|
||||
defaultedKeys: string[];
|
||||
}
|
||||
|
||||
interface PlaceholderHit {
|
||||
@@ -18,13 +24,16 @@ interface PlaceholderHit {
|
||||
|
||||
const changeMePattern = /CHANGE_ME[A-Z0-9_]*/g;
|
||||
|
||||
export function transformEnv(input: string): TransformResult {
|
||||
const hits = collectHits(input);
|
||||
export function transformEnv(input: string, defaults: EnvDefault[] = []): TransformResult {
|
||||
const defaultMap = normalizeDefaults(defaults);
|
||||
const defaultedKeys: string[] = [];
|
||||
const inputWithDefaults = applyDefaults(input, defaultMap, defaultedKeys);
|
||||
const hits = collectHits(inputWithDefaults);
|
||||
const generated = new Map<string, Replacement>();
|
||||
|
||||
for (const hit of hits) {
|
||||
if (!generated.has(hit.placeholder)) {
|
||||
const requirement = inferRequirement(hit.key, hit.placeholder);
|
||||
const requirement = inferRequirement(hit.placeholder, hit.key);
|
||||
generated.set(hit.placeholder, {
|
||||
placeholder: hit.placeholder,
|
||||
value: generateValue(requirement),
|
||||
@@ -39,7 +48,7 @@ export function transformEnv(input: string): TransformResult {
|
||||
}
|
||||
}
|
||||
|
||||
let output = input;
|
||||
let output = inputWithDefaults;
|
||||
const orderedReplacements = [...generated.values()].sort((a, b) => b.placeholder.length - a.placeholder.length);
|
||||
for (const replacement of orderedReplacements) {
|
||||
output = output.replaceAll(replacement.placeholder, replacement.value);
|
||||
@@ -48,10 +57,43 @@ export function transformEnv(input: string): TransformResult {
|
||||
return {
|
||||
output,
|
||||
replacements: [...generated.values()].sort((a, b) => a.placeholder.localeCompare(b.placeholder)),
|
||||
changedCount: hits.length
|
||||
changedCount: hits.length + defaultedKeys.length,
|
||||
defaultedKeys
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDefaults(defaults: EnvDefault[]): Map<string, string> {
|
||||
const defaultMap = new Map<string, string>();
|
||||
|
||||
for (const entry of defaults) {
|
||||
const key = entry.key.trim();
|
||||
if (key && entry.value.length > 0) {
|
||||
defaultMap.set(key, entry.value);
|
||||
}
|
||||
}
|
||||
|
||||
return defaultMap;
|
||||
}
|
||||
|
||||
function applyDefaults(input: string, defaults: Map<string, string>, defaultedKeys: string[]): string {
|
||||
if (defaults.size === 0) {
|
||||
return input;
|
||||
}
|
||||
|
||||
return input
|
||||
.split(/\r?\n/)
|
||||
.map((line) => {
|
||||
const parsed = parseAssignment(line);
|
||||
if (!parsed || !defaults.has(parsed.key)) {
|
||||
return line;
|
||||
}
|
||||
|
||||
defaultedKeys.push(parsed.key);
|
||||
return `${parsed.prefix}${parsed.key}=${defaults.get(parsed.key)}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function collectHits(input: string): PlaceholderHit[] {
|
||||
const hits: PlaceholderHit[] = [];
|
||||
|
||||
@@ -73,13 +115,17 @@ function collectHits(input: string): PlaceholderHit[] {
|
||||
return hits;
|
||||
}
|
||||
|
||||
function parseAssignment(line: string): { key: string; value: string } | null {
|
||||
function parseAssignment(line: string): { key: string; value: string; prefix: string } | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = trimmed.startsWith("export ") ? trimmed.slice(7).trimStart() : trimmed;
|
||||
const leadingWhitespace = line.match(/^\s*/)?.[0] ?? "";
|
||||
const withoutLeading = line.slice(leadingWhitespace.length);
|
||||
const hasExport = withoutLeading.startsWith("export ");
|
||||
const prefix = hasExport ? `${leadingWhitespace}export ` : leadingWhitespace;
|
||||
const normalized = hasExport ? withoutLeading.slice(7).trimStart() : withoutLeading;
|
||||
const equalsIndex = normalized.indexOf("=");
|
||||
if (equalsIndex <= 0) {
|
||||
return null;
|
||||
@@ -87,7 +133,8 @@ function parseAssignment(line: string): { key: string; value: string } | null {
|
||||
|
||||
return {
|
||||
key: normalized.slice(0, equalsIndex).trim(),
|
||||
value: normalized.slice(equalsIndex + 1).trim()
|
||||
value: normalized.slice(equalsIndex + 1).trim(),
|
||||
prefix
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,12 +148,18 @@ type Requirement =
|
||||
| { type: "secret"; length: number; label: string }
|
||||
| { type: "password"; length: number; urlSafe: boolean; label: string };
|
||||
|
||||
function inferRequirement(key: string, placeholder: string): Requirement {
|
||||
const signal = `${key}_${placeholder}`.toUpperCase();
|
||||
function inferRequirement(placeholder: string, key: string): Requirement {
|
||||
const placeholderSignal = placeholder.toUpperCase();
|
||||
const keySignal = key.toUpperCase();
|
||||
const combinedSignal = `${placeholderSignal}_${keySignal}`;
|
||||
|
||||
const byteCount = extractNumberBefore(signal, "RANDOM_BYTES") ?? extractNumberBefore(signal, "BYTES");
|
||||
const byteCount =
|
||||
extractNumberBefore(placeholderSignal, "RANDOM_BYTES") ??
|
||||
extractNumberBefore(placeholderSignal, "BYTES") ??
|
||||
extractNumberBefore(combinedSignal, "RANDOM_BYTES") ??
|
||||
extractNumberBefore(combinedSignal, "BYTES");
|
||||
|
||||
if (signal.includes("BASE64")) {
|
||||
if (placeholderSignal.includes("BASE64") || keySignal.includes("BASE64")) {
|
||||
return {
|
||||
type: "base64",
|
||||
bytes: byteCount ?? 32,
|
||||
@@ -114,38 +167,49 @@ function inferRequirement(key: string, placeholder: string): Requirement {
|
||||
};
|
||||
}
|
||||
|
||||
if (signal.includes("UUID")) {
|
||||
if (placeholderSignal.includes("UUID") || keySignal.includes("UUID")) {
|
||||
return { type: "uuid", label: "UUID v4" };
|
||||
}
|
||||
|
||||
if (signal.includes("HEX")) {
|
||||
if (placeholderSignal.includes("HEX") || keySignal.includes("HEX")) {
|
||||
return { type: "hex", bytes: byteCount ?? 32, label: `${byteCount ?? 32} random bytes as hex` };
|
||||
}
|
||||
|
||||
if (signal.includes("PORT")) {
|
||||
if (placeholderSignal.includes("PORT") || keySignal.includes("PORT")) {
|
||||
return { type: "integer", min: 1024, max: 49151, label: "TCP port number" };
|
||||
}
|
||||
|
||||
if (signal.includes("EMAIL")) {
|
||||
if (placeholderSignal.includes("EMAIL") || keySignal.includes("EMAIL")) {
|
||||
return { type: "email", label: "email address" };
|
||||
}
|
||||
|
||||
if (signal.includes("URL") || signal.includes("URI") || signal.includes("ORIGIN")) {
|
||||
return { type: "url", label: "HTTPS URL" };
|
||||
}
|
||||
|
||||
if (signal.includes("PASSWORD")) {
|
||||
const length = signal.includes("LONG") || signal.includes("ADMIN") ? 28 : 24;
|
||||
if (placeholderSignal.includes("PASSWORD") || keySignal.includes("PASSWORD")) {
|
||||
const length = placeholderSignal.includes("LONG") || keySignal.includes("ADMIN") ? 28 : 24;
|
||||
return {
|
||||
type: "password",
|
||||
length,
|
||||
urlSafe: signal.includes("DATABASE") || signal.includes("POSTGRES") || signal.includes("MYSQL"),
|
||||
urlSafe: combinedSignal.includes("DATABASE") || combinedSignal.includes("POSTGRES") || combinedSignal.includes("MYSQL"),
|
||||
label: `${length} character strong password`
|
||||
};
|
||||
}
|
||||
|
||||
const minimumLength = extractNumberAfter(signal, "AT_LEAST") ?? 48;
|
||||
if (signal.includes("SECRET") || signal.includes("TOKEN") || signal.includes("KEY")) {
|
||||
if (placeholderSignal.includes("URL") || placeholderSignal.includes("URI") || placeholderSignal.includes("ORIGIN")) {
|
||||
return { type: "url", label: "HTTPS URL" };
|
||||
}
|
||||
|
||||
if (keySignal.includes("URL") || keySignal.includes("URI") || keySignal.includes("ORIGIN")) {
|
||||
return { type: "url", label: "HTTPS URL" };
|
||||
}
|
||||
|
||||
const minimumLength = extractNumberAfter(placeholderSignal, "AT_LEAST") ?? extractNumberAfter(combinedSignal, "AT_LEAST") ?? 48;
|
||||
if (
|
||||
placeholderSignal.includes("SECRET") ||
|
||||
placeholderSignal.includes("TOKEN") ||
|
||||
placeholderSignal.includes("KEY") ||
|
||||
keySignal.includes("SECRET") ||
|
||||
keySignal.includes("TOKEN") ||
|
||||
keySignal.includes("KEY")
|
||||
) {
|
||||
return {
|
||||
type: "secret",
|
||||
length: Math.max(minimumLength, 32),
|
||||
|
||||
577
src/styles.css
577
src/styles.css
@@ -1,28 +1,95 @@
|
||||
:root {
|
||||
color: #17211c;
|
||||
background: #f5f5ef;
|
||||
color-scheme: light;
|
||||
--bg: #eef1ed;
|
||||
--bg-soft: #f7f8f5;
|
||||
--surface: rgba(255, 255, 255, 0.92);
|
||||
--surface-solid: #ffffff;
|
||||
--surface-subtle: #f0f4ef;
|
||||
--text: #18231e;
|
||||
--muted: #62706a;
|
||||
--border: #d8dfd6;
|
||||
--accent: #1f665a;
|
||||
--accent-strong: #154d44;
|
||||
--accent-soft: #e2eee9;
|
||||
--danger: #9f3e34;
|
||||
--editor-bg: #0d1714;
|
||||
--editor-text: #eef8f3;
|
||||
--shadow: 0 18px 45px rgba(42, 56, 49, 0.12);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[data-theme="system"] {
|
||||
color-scheme: dark;
|
||||
--bg: #111614;
|
||||
--bg-soft: #151d1a;
|
||||
--surface: rgba(26, 35, 31, 0.94);
|
||||
--surface-solid: #1a231f;
|
||||
--surface-subtle: #202c27;
|
||||
--text: #e8eee9;
|
||||
--muted: #a2afa9;
|
||||
--border: #34413b;
|
||||
--accent: #65c0ad;
|
||||
--accent-strong: #8bd8c7;
|
||||
--accent-soft: #233c36;
|
||||
--danger: #ef8b7d;
|
||||
--editor-bg: #07100d;
|
||||
--editor-text: #f1fbf7;
|
||||
--shadow: 0 18px 45px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #111614;
|
||||
--bg-soft: #151d1a;
|
||||
--surface: rgba(26, 35, 31, 0.94);
|
||||
--surface-solid: #1a231f;
|
||||
--surface-subtle: #202c27;
|
||||
--text: #e8eee9;
|
||||
--muted: #a2afa9;
|
||||
--border: #34413b;
|
||||
--accent: #65c0ad;
|
||||
--accent-strong: #8bd8c7;
|
||||
--accent-soft: #233c36;
|
||||
--danger: #ef8b7d;
|
||||
--editor-bg: #07100d;
|
||||
--editor-text: #f1fbf7;
|
||||
--shadow: 0 18px 45px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 940px;
|
||||
min-height: 100vh;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(41, 94, 82, 0.12), transparent 36%),
|
||||
linear-gradient(315deg, rgba(190, 78, 61, 0.1), transparent 34%),
|
||||
#f5f5ef;
|
||||
radial-gradient(circle at 0 0, color-mix(in srgb, var(--accent) 16%, transparent), transparent 30%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
button,
|
||||
textarea {
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@@ -32,15 +99,17 @@ button {
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 750;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
min-height: 42px;
|
||||
padding: 0 16px;
|
||||
min-height: 36px;
|
||||
padding: 0 13px;
|
||||
transition:
|
||||
transform 140ms ease,
|
||||
box-shadow 140ms ease,
|
||||
background 140ms ease;
|
||||
background 140ms ease,
|
||||
border-color 140ms ease,
|
||||
color 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
@@ -49,37 +118,64 @@ button:hover:not(:disabled) {
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
margin: 0 auto;
|
||||
max-width: 1420px;
|
||||
min-height: 100vh;
|
||||
padding: 28px;
|
||||
input {
|
||||
background: var(--surface-solid);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
min-height: 36px;
|
||||
min-width: 0;
|
||||
outline: none;
|
||||
padding: 0 11px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
justify-content: space-between;
|
||||
padding: 10px 2px 4px;
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
.appShell {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-rows: auto auto minmax(260px, 1fr) minmax(160px, 0.52fr);
|
||||
height: 100%;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
align-items: center;
|
||||
color: #2d6f62;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
gap: 7px;
|
||||
letter-spacing: 0;
|
||||
margin: 0 0 10px;
|
||||
text-transform: uppercase;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
min-height: 64px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 11px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.brandMark {
|
||||
align-items: center;
|
||||
background: var(--accent);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
height: 38px;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
}
|
||||
|
||||
h1,
|
||||
@@ -89,212 +185,369 @@ p {
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #13201b;
|
||||
font-size: 2.8rem;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.02;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
font-size: 0.98rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.subline {
|
||||
color: #53635d;
|
||||
font-size: 1.04rem;
|
||||
line-height: 1.5;
|
||||
margin-top: 12px;
|
||||
max-width: 720px;
|
||||
.brand p,
|
||||
.panelHeader p,
|
||||
.panelHeading p,
|
||||
.settingsHeader p,
|
||||
.statItem p {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.35;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.heroActions,
|
||||
.toolbarActions,
|
||||
.buttonRow {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: #1f5d53;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 10px 24px rgba(31, 93, 83, 0.18);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 22px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: #ffffff;
|
||||
color: #1f332d;
|
||||
box-shadow: inset 0 0 0 1px #d9ded5;
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.secondary,
|
||||
.iconButton,
|
||||
.segmented {
|
||||
background: var(--surface-solid);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
background: #eef2ec;
|
||||
color: #26372f;
|
||||
min-width: 42px;
|
||||
min-width: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
.iconButton.subtle {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.stats div {
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border: 1px solid #dde3da;
|
||||
.segmented {
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.stats span {
|
||||
color: #9b3f31;
|
||||
.segmented button {
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
min-height: 30px;
|
||||
min-width: 42px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.segmented button.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.statusStrip {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.statItem {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.statItem span {
|
||||
color: var(--danger);
|
||||
display: block;
|
||||
font-size: 1.35rem;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 850;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stats p,
|
||||
.panelHeader p,
|
||||
.replacement p {
|
||||
color: #64726d;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.4;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
.workbench {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 18px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
min-height: 430px;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.replacementPanel {
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
border: 1px solid #dbe2d8;
|
||||
.editorPanel,
|
||||
.dataPanel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 20px 54px rgba(54, 67, 61, 0.1);
|
||||
box-shadow: var(--shadow);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
.editorPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
.panelHeader,
|
||||
.panelHeading {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e2e6de;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panelHeader.compact {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 10px;
|
||||
min-height: 62px;
|
||||
padding: 11px 12px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
background: #101815;
|
||||
background: var(--editor-bg);
|
||||
border: 0;
|
||||
color: #e7eee8;
|
||||
color: var(--editor-text);
|
||||
flex: 1;
|
||||
font-family: "Cascadia Code", "Fira Code", Consolas, monospace;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.55;
|
||||
min-height: 360px;
|
||||
min-height: 0;
|
||||
outline: none;
|
||||
padding: 18px;
|
||||
padding: 15px;
|
||||
resize: none;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
textarea::selection {
|
||||
background: #be4e3d;
|
||||
color: #ffffff;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.replacementPanel {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.replacementGrid {
|
||||
.lowerGrid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.replacement {
|
||||
align-items: flex-start;
|
||||
background: #f7f8f4;
|
||||
border: 1px solid #e1e6dd;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
min-height: 88px;
|
||||
padding: 13px;
|
||||
grid-template-columns: minmax(320px, 0.72fr) minmax(0, 1.28fr);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.replacement strong {
|
||||
color: #26372f;
|
||||
display: block;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.35;
|
||||
.dataPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.panelHeading {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.defaultsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0 12px 10px;
|
||||
}
|
||||
|
||||
.defaultRow {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(120px, 0.9fr) minmax(140px, 1.1fr) 36px;
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.requirementsTable {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin: 0 12px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tableHeader,
|
||||
.tableRow {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(180px, 0.95fr) minmax(160px, 1fr) minmax(150px, 0.8fr);
|
||||
min-width: 620px;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 850;
|
||||
padding: 9px 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tableRow {
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.tableRow code {
|
||||
color: var(--text);
|
||||
font-family: "Cascadia Code", Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.replacement span {
|
||||
background: #e6efe8;
|
||||
border-radius: 999px;
|
||||
color: #23594f;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
max-width: 42%;
|
||||
padding: 6px 9px;
|
||||
.tableRow span {
|
||||
color: var(--muted);
|
||||
font-size: 0.84rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tableRow strong {
|
||||
color: var(--accent-strong);
|
||||
font-size: 0.82rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #66736d;
|
||||
padding: 0 16px 4px;
|
||||
color: var(--muted);
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shell {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero {
|
||||
.settingsOverlay {
|
||||
align-items: stretch;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
inset: 0;
|
||||
justify-content: flex-end;
|
||||
padding: 14px;
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.settingsPanel {
|
||||
background: var(--surface-solid);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 380px;
|
||||
min-width: 320px;
|
||||
padding: 14px;
|
||||
width: 28vw;
|
||||
}
|
||||
|
||||
.settingsHeader {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.settingsGroup {
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.settingsGroup label {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 800;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.languageGrid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.languageGrid button {
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.languageGrid button.selected {
|
||||
background: var(--accent-soft);
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.settingsMeta {
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.78rem;
|
||||
gap: 6px;
|
||||
margin-top: auto;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.appShell {
|
||||
grid-template-rows: auto auto minmax(420px, 1fr) minmax(320px, auto);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.heroActions {
|
||||
body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.toolbarActions {
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
.replacementGrid,
|
||||
.stats {
|
||||
.workbench,
|
||||
.lowerGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.appShell {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.statusStrip {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.toolbarActions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbarActions .secondary,
|
||||
.toolbarActions .segmented {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.defaultRow {
|
||||
grid-template-columns: 1fr 1fr 36px;
|
||||
}
|
||||
|
||||
.settingsPanel {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user