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
|
||||||
|
|
||||||
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
|
- `.env` Datei laden oder reinen Text einfügen
|
||||||
- `CHANGE_ME...` Platzhalter erkennen
|
- `CHANGE_ME...` Platzhalter automatisch erkennen
|
||||||
- passende Werte anhand von Variablenname und Platzhalter ableiten
|
- Passwörter, Secrets, Base64-Keys, UUIDs, Ports, URLs und E-Mails heuristisch erzeugen
|
||||||
- gleiche Platzhalter konsistent mit demselben Wert ersetzen
|
- Gleiche Placeholder konsistent mit demselben Wert ersetzen
|
||||||
- neue `.env` als Text kopieren oder speichern
|
- 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
|
## Entwicklung
|
||||||
|
|
||||||
@@ -17,7 +91,7 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
In einem zweiten Terminal:
|
In einem zweiten Terminal kann der Electron-Build geprüft werden:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
@@ -25,10 +99,22 @@ npm run build
|
|||||||
|
|
||||||
## Windows 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
|
```bash
|
||||||
npm run dist:win
|
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`
|
||||||
|
|||||||
570
src/App.tsx
570
src/App.tsx
@@ -1,6 +1,25 @@
|
|||||||
import { Check, Clipboard, FileDown, FileInput, RefreshCcw, ShieldCheck, Sparkles } from "lucide-react";
|
import {
|
||||||
import { useMemo, useState } from "react";
|
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 { 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
|
const sampleEnv = `APP_IMAGE=git.wilkensxl.de/mrsphay/hilden-directory-gateway:latest
|
||||||
APP_PORT=3000
|
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_EMAIL=admin@example.local
|
||||||
BOOTSTRAP_ADMIN_PASSWORD=CHANGE_ME_LONG_INITIAL_ADMIN_PASSWORD`;
|
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() {
|
export default function App() {
|
||||||
const [input, setInput] = useState(sampleEnv);
|
const [input, setInput] = useState(sampleEnv);
|
||||||
const [loadedPath, setLoadedPath] = useState<string | null>(null);
|
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 [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() {
|
async function openFile() {
|
||||||
const file = await window.envHelper?.openFile();
|
const file = await window.envHelper?.openFile();
|
||||||
@@ -39,112 +310,219 @@ export default function App() {
|
|||||||
|
|
||||||
async function copyOutput() {
|
async function copyOutput() {
|
||||||
await navigator.clipboard.writeText(result.output);
|
await navigator.clipboard.writeText(result.output);
|
||||||
setCopyLabel("Kopiert");
|
setCopyLabel("copied");
|
||||||
window.setTimeout(() => setCopyLabel("Kopieren"), 1400);
|
window.setTimeout(() => setCopyLabel("copy"), 1400);
|
||||||
}
|
}
|
||||||
|
|
||||||
function regenerate() {
|
function updateDefault(index: number, field: keyof EnvDefault, value: string) {
|
||||||
setGeneration((current) => current + 1);
|
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 (
|
return (
|
||||||
<main className="shell">
|
<main className="appShell">
|
||||||
<section className="hero">
|
<header className="toolbar">
|
||||||
<div>
|
<div className="brand">
|
||||||
<p className="eyebrow"><Sparkles size={16} /> EnvHelper</p>
|
<div className="brandMark">EH</div>
|
||||||
<h1>.env Platzhalter sauber ersetzen</h1>
|
<div>
|
||||||
<p className="subline">
|
<h1>EnvHelper</h1>
|
||||||
Erkennt CHANGE_ME Werte, erzeugt passende Secrets im richtigen Format und hält gleiche Platzhalter synchron.
|
<p>{t.appSubtitle}</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="heroActions">
|
|
||||||
|
<div className="toolbarActions">
|
||||||
<button className="secondary" onClick={openFile} type="button">
|
<button className="secondary" onClick={openFile} type="button">
|
||||||
<FileInput size={18} /> Datei laden
|
<FileInput size={17} /> {t.loadFile}
|
||||||
</button>
|
</button>
|
||||||
<button className="secondary" onClick={saveFile} type="button" disabled={!result.output}>
|
<button className="secondary" onClick={saveFile} type="button" disabled={!result.output}>
|
||||||
<FileDown size={18} /> .env speichern
|
<FileDown size={17} /> {t.saveFile}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<div className="segmented" aria-label={t.theme}>
|
||||||
</section>
|
{themeLabels.map((mode) => (
|
||||||
|
<button
|
||||||
<section className="stats">
|
className={themeMode === mode ? "active" : ""}
|
||||||
<div>
|
key={mode}
|
||||||
<span>{result.changedCount}</span>
|
onClick={() => setThemeMode(mode)}
|
||||||
<p>Fundstellen</p>
|
type="button"
|
||||||
</div>
|
title={t[mode]}
|
||||||
<div>
|
>
|
||||||
<span>{result.replacements.length}</span>
|
{mode === "system" ? <SunMoon size={15} /> : t[mode]}
|
||||||
<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} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
spellCheck={false}
|
|
||||||
value={input}
|
|
||||||
onChange={(event) => setInput(event.target.value)}
|
|
||||||
aria-label="Env input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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} />
|
|
||||||
</button>
|
</button>
|
||||||
<button className="primary" onClick={copyOutput} type="button">
|
|
||||||
{copyLabel === "Kopiert" ? <Check size={18} /> : <Clipboard size={18} />} {copyLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<textarea spellCheck={false} value={result.output} readOnly aria-label="Env output" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="replacementPanel">
|
|
||||||
<div className="panelHeader compact">
|
|
||||||
<div>
|
|
||||||
<h2>Erkannte Anforderungen</h2>
|
|
||||||
<p>Formate werden aus Variablennamen und Platzhaltertext abgeleitet.</p>
|
|
||||||
</div>
|
|
||||||
<ShieldCheck size={22} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{result.replacements.length === 0 ? (
|
|
||||||
<div className="empty">Keine CHANGE_ME Platzhalter gefunden.</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>
|
|
||||||
<span>{replacement.format}</span>
|
|
||||||
</article>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
<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>
|
</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[];
|
keys: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EnvDefault {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TransformResult {
|
export interface TransformResult {
|
||||||
output: string;
|
output: string;
|
||||||
replacements: Replacement[];
|
replacements: Replacement[];
|
||||||
changedCount: number;
|
changedCount: number;
|
||||||
|
defaultedKeys: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlaceholderHit {
|
interface PlaceholderHit {
|
||||||
@@ -18,13 +24,16 @@ interface PlaceholderHit {
|
|||||||
|
|
||||||
const changeMePattern = /CHANGE_ME[A-Z0-9_]*/g;
|
const changeMePattern = /CHANGE_ME[A-Z0-9_]*/g;
|
||||||
|
|
||||||
export function transformEnv(input: string): TransformResult {
|
export function transformEnv(input: string, defaults: EnvDefault[] = []): TransformResult {
|
||||||
const hits = collectHits(input);
|
const defaultMap = normalizeDefaults(defaults);
|
||||||
|
const defaultedKeys: string[] = [];
|
||||||
|
const inputWithDefaults = applyDefaults(input, defaultMap, defaultedKeys);
|
||||||
|
const hits = collectHits(inputWithDefaults);
|
||||||
const generated = new Map<string, Replacement>();
|
const generated = new Map<string, Replacement>();
|
||||||
|
|
||||||
for (const hit of hits) {
|
for (const hit of hits) {
|
||||||
if (!generated.has(hit.placeholder)) {
|
if (!generated.has(hit.placeholder)) {
|
||||||
const requirement = inferRequirement(hit.key, hit.placeholder);
|
const requirement = inferRequirement(hit.placeholder, hit.key);
|
||||||
generated.set(hit.placeholder, {
|
generated.set(hit.placeholder, {
|
||||||
placeholder: hit.placeholder,
|
placeholder: hit.placeholder,
|
||||||
value: generateValue(requirement),
|
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);
|
const orderedReplacements = [...generated.values()].sort((a, b) => b.placeholder.length - a.placeholder.length);
|
||||||
for (const replacement of orderedReplacements) {
|
for (const replacement of orderedReplacements) {
|
||||||
output = output.replaceAll(replacement.placeholder, replacement.value);
|
output = output.replaceAll(replacement.placeholder, replacement.value);
|
||||||
@@ -48,10 +57,43 @@ export function transformEnv(input: string): TransformResult {
|
|||||||
return {
|
return {
|
||||||
output,
|
output,
|
||||||
replacements: [...generated.values()].sort((a, b) => a.placeholder.localeCompare(b.placeholder)),
|
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[] {
|
function collectHits(input: string): PlaceholderHit[] {
|
||||||
const hits: PlaceholderHit[] = [];
|
const hits: PlaceholderHit[] = [];
|
||||||
|
|
||||||
@@ -73,13 +115,17 @@ function collectHits(input: string): PlaceholderHit[] {
|
|||||||
return hits;
|
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();
|
const trimmed = line.trim();
|
||||||
if (!trimmed || trimmed.startsWith("#")) {
|
if (!trimmed || trimmed.startsWith("#")) {
|
||||||
return null;
|
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("=");
|
const equalsIndex = normalized.indexOf("=");
|
||||||
if (equalsIndex <= 0) {
|
if (equalsIndex <= 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -87,7 +133,8 @@ function parseAssignment(line: string): { key: string; value: string } | null {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
key: normalized.slice(0, equalsIndex).trim(),
|
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: "secret"; length: number; label: string }
|
||||||
| { type: "password"; length: number; urlSafe: boolean; label: string };
|
| { type: "password"; length: number; urlSafe: boolean; label: string };
|
||||||
|
|
||||||
function inferRequirement(key: string, placeholder: string): Requirement {
|
function inferRequirement(placeholder: string, key: string): Requirement {
|
||||||
const signal = `${key}_${placeholder}`.toUpperCase();
|
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 {
|
return {
|
||||||
type: "base64",
|
type: "base64",
|
||||||
bytes: byteCount ?? 32,
|
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" };
|
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` };
|
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" };
|
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" };
|
return { type: "email", label: "email address" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (signal.includes("URL") || signal.includes("URI") || signal.includes("ORIGIN")) {
|
if (placeholderSignal.includes("PASSWORD") || keySignal.includes("PASSWORD")) {
|
||||||
return { type: "url", label: "HTTPS URL" };
|
const length = placeholderSignal.includes("LONG") || keySignal.includes("ADMIN") ? 28 : 24;
|
||||||
}
|
|
||||||
|
|
||||||
if (signal.includes("PASSWORD")) {
|
|
||||||
const length = signal.includes("LONG") || signal.includes("ADMIN") ? 28 : 24;
|
|
||||||
return {
|
return {
|
||||||
type: "password",
|
type: "password",
|
||||||
length,
|
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`
|
label: `${length} character strong password`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const minimumLength = extractNumberAfter(signal, "AT_LEAST") ?? 48;
|
if (placeholderSignal.includes("URL") || placeholderSignal.includes("URI") || placeholderSignal.includes("ORIGIN")) {
|
||||||
if (signal.includes("SECRET") || signal.includes("TOKEN") || signal.includes("KEY")) {
|
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 {
|
return {
|
||||||
type: "secret",
|
type: "secret",
|
||||||
length: Math.max(minimumLength, 32),
|
length: Math.max(minimumLength, 32),
|
||||||
|
|||||||
577
src/styles.css
577
src/styles.css
@@ -1,28 +1,95 @@
|
|||||||
:root {
|
:root {
|
||||||
color: #17211c;
|
color-scheme: light;
|
||||||
background: #f5f5ef;
|
--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:
|
font-family:
|
||||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 940px;
|
min-width: 0;
|
||||||
min-height: 100vh;
|
overflow: hidden;
|
||||||
background:
|
background:
|
||||||
linear-gradient(135deg, rgba(41, 94, 82, 0.12), transparent 36%),
|
radial-gradient(circle at 0 0, color-mix(in srgb, var(--accent) 16%, transparent), transparent 30%),
|
||||||
linear-gradient(315deg, rgba(190, 78, 61, 0.1), transparent 34%),
|
var(--bg);
|
||||||
#f5f5ef;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
textarea {
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,15 +99,17 @@ button {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
font-weight: 700;
|
font-size: 0.9rem;
|
||||||
|
font-weight: 750;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 42px;
|
min-height: 36px;
|
||||||
padding: 0 16px;
|
padding: 0 13px;
|
||||||
transition:
|
transition:
|
||||||
transform 140ms ease,
|
background 140ms ease,
|
||||||
box-shadow 140ms ease,
|
border-color 140ms ease,
|
||||||
background 140ms ease;
|
color 140ms ease,
|
||||||
|
transform 140ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover:not(:disabled) {
|
button:hover:not(:disabled) {
|
||||||
@@ -49,37 +118,64 @@ button:hover:not(:disabled) {
|
|||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.5;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell {
|
input {
|
||||||
display: flex;
|
background: var(--surface-solid);
|
||||||
flex-direction: column;
|
border: 1px solid var(--border);
|
||||||
gap: 18px;
|
border-radius: 8px;
|
||||||
margin: 0 auto;
|
color: var(--text);
|
||||||
max-width: 1420px;
|
min-height: 36px;
|
||||||
min-height: 100vh;
|
min-width: 0;
|
||||||
padding: 28px;
|
outline: none;
|
||||||
|
padding: 0 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
input:focus,
|
||||||
align-items: flex-end;
|
textarea:focus {
|
||||||
display: flex;
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||||
gap: 24px;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 10px 2px 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
align-items: center;
|
||||||
color: #2d6f62;
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
display: flex;
|
display: flex;
|
||||||
font-size: 0.85rem;
|
gap: 16px;
|
||||||
font-weight: 800;
|
justify-content: space-between;
|
||||||
gap: 7px;
|
min-height: 64px;
|
||||||
letter-spacing: 0;
|
padding: 10px 12px;
|
||||||
margin: 0 0 10px;
|
}
|
||||||
text-transform: uppercase;
|
|
||||||
|
.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,
|
h1,
|
||||||
@@ -89,212 +185,369 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
color: #13201b;
|
font-size: 1.15rem;
|
||||||
font-size: 2.8rem;
|
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
line-height: 1.02;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1rem;
|
font-size: 0.98rem;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subline {
|
.brand p,
|
||||||
color: #53635d;
|
.panelHeader p,
|
||||||
font-size: 1.04rem;
|
.panelHeading p,
|
||||||
line-height: 1.5;
|
.settingsHeader p,
|
||||||
margin-top: 12px;
|
.statItem p {
|
||||||
max-width: 720px;
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroActions,
|
.toolbarActions,
|
||||||
.buttonRow {
|
.buttonRow {
|
||||||
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary {
|
.primary {
|
||||||
background: #1f5d53;
|
background: var(--accent);
|
||||||
color: #ffffff;
|
color: #fff;
|
||||||
box-shadow: 0 10px 24px rgba(31, 93, 83, 0.18);
|
box-shadow: 0 10px 22px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
.primary:hover:not(:disabled) {
|
||||||
background: #ffffff;
|
background: var(--accent-strong);
|
||||||
color: #1f332d;
|
}
|
||||||
box-shadow: inset 0 0 0 1px #d9ded5;
|
|
||||||
|
.secondary,
|
||||||
|
.iconButton,
|
||||||
|
.segmented {
|
||||||
|
background: var(--surface-solid);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconButton {
|
.iconButton {
|
||||||
background: #eef2ec;
|
min-width: 36px;
|
||||||
color: #26372f;
|
|
||||||
min-width: 42px;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
.iconButton.subtle {
|
||||||
display: grid;
|
color: var(--muted);
|
||||||
gap: 12px;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats div {
|
.segmented {
|
||||||
background: rgba(255, 255, 255, 0.78);
|
|
||||||
border: 1px solid #dde3da;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 14px 16px;
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats span {
|
.segmented button {
|
||||||
color: #9b3f31;
|
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;
|
display: block;
|
||||||
font-size: 1.35rem;
|
font-size: 1.08rem;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats p,
|
.workbench {
|
||||||
.panelHeader p,
|
|
||||||
.replacement p {
|
|
||||||
color: #64726d;
|
|
||||||
font-size: 0.86rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 1;
|
gap: 12px;
|
||||||
gap: 18px;
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
min-height: 0;
|
||||||
min-height: 430px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel,
|
.editorPanel,
|
||||||
.replacementPanel {
|
.dataPanel {
|
||||||
background: rgba(255, 255, 255, 0.86);
|
background: var(--surface);
|
||||||
border: 1px solid #dbe2d8;
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelHeader {
|
.panelHeader,
|
||||||
|
.panelHeading {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid #e2e6de;
|
border-bottom: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 16px;
|
min-height: 62px;
|
||||||
}
|
padding: 11px 12px;
|
||||||
|
|
||||||
.panelHeader.compact {
|
|
||||||
border-bottom: 0;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
background: #101815;
|
background: var(--editor-bg);
|
||||||
border: 0;
|
border: 0;
|
||||||
color: #e7eee8;
|
color: var(--editor-text);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
font-family: "Cascadia Code", "Fira Code", Consolas, monospace;
|
||||||
|
font-size: 0.92rem;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
min-height: 360px;
|
min-height: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: 18px;
|
padding: 15px;
|
||||||
resize: none;
|
resize: none;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea::selection {
|
textarea::selection {
|
||||||
background: #be4e3d;
|
background: var(--accent);
|
||||||
color: #ffffff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.replacementPanel {
|
.lowerGrid {
|
||||||
padding-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.replacementGrid {
|
|
||||||
display: grid;
|
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;
|
gap: 12px;
|
||||||
justify-content: space-between;
|
grid-template-columns: minmax(320px, 0.72fr) minmax(0, 1.28fr);
|
||||||
min-height: 88px;
|
min-height: 0;
|
||||||
padding: 13px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.replacement strong {
|
.dataPanel {
|
||||||
color: #26372f;
|
display: flex;
|
||||||
display: block;
|
flex-direction: column;
|
||||||
font-size: 0.86rem;
|
min-height: 0;
|
||||||
line-height: 1.35;
|
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;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.replacement span {
|
.tableRow span {
|
||||||
background: #e6efe8;
|
color: var(--muted);
|
||||||
border-radius: 999px;
|
font-size: 0.84rem;
|
||||||
color: #23594f;
|
overflow-wrap: anywhere;
|
||||||
flex: 0 0 auto;
|
}
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 800;
|
.tableRow strong {
|
||||||
max-width: 42%;
|
color: var(--accent-strong);
|
||||||
padding: 6px 9px;
|
font-size: 0.82rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
color: #66736d;
|
color: var(--muted);
|
||||||
padding: 0 16px 4px;
|
padding: 0 12px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1050px) {
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-width: 0;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell {
|
.toolbar,
|
||||||
padding: 18px;
|
.toolbarActions {
|
||||||
}
|
|
||||||
|
|
||||||
.hero,
|
|
||||||
.workspace {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heroActions {
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.workbench,
|
||||||
font-size: 2.25rem;
|
.lowerGrid {
|
||||||
}
|
|
||||||
|
|
||||||
.replacementGrid,
|
|
||||||
.stats {
|
|
||||||
grid-template-columns: 1fr;
|
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