801 lines
24 KiB
TypeScript
801 lines
24 KiB
TypeScript
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";
|
|
type DefaultSource = "auto" | "manual";
|
|
|
|
interface DefaultRow extends EnvDefault {
|
|
source: DefaultSource;
|
|
index?: number;
|
|
}
|
|
|
|
const initialDefaults: EnvDefault[] = [];
|
|
|
|
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",
|
|
hits: "Fundstellen",
|
|
values: "Werte",
|
|
defaultsApplied: "Defaults",
|
|
source: "Quelle",
|
|
file: "Datei",
|
|
text: "Text",
|
|
defaults: "Standardwerte",
|
|
defaultsHint: "Mögliche Standardwerte werden erkannt; eigene Werte überschreiben passende Keys im Output.",
|
|
addDefault: "Standardwert hinzufügen",
|
|
autoDefault: "Auto",
|
|
manualDefault: "Manuell",
|
|
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",
|
|
hits: "Matches",
|
|
values: "Values",
|
|
defaultsApplied: "Defaults",
|
|
source: "Source",
|
|
file: "File",
|
|
text: "Text",
|
|
defaults: "Defaults",
|
|
defaultsHint: "Possible defaults are detected; custom values override matching keys in the output.",
|
|
addDefault: "Add default",
|
|
autoDefault: "Auto",
|
|
manualDefault: "Manual",
|
|
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",
|
|
hits: "Coincidencias",
|
|
values: "Valores",
|
|
defaultsApplied: "Defaults",
|
|
source: "Origen",
|
|
file: "Archivo",
|
|
text: "Texto",
|
|
defaults: "Valores predeterminados",
|
|
defaultsHint: "Se detectan valores posibles; los personalizados sobrescriben claves iguales.",
|
|
addDefault: "Añadir valor",
|
|
autoDefault: "Auto",
|
|
manualDefault: "Manual",
|
|
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",
|
|
hits: "Occurrences",
|
|
values: "Valeurs",
|
|
defaultsApplied: "Défauts",
|
|
source: "Source",
|
|
file: "Fichier",
|
|
text: "Texte",
|
|
defaults: "Valeurs par défaut",
|
|
defaultsHint: "Les valeurs possibles sont détectées; les valeurs personnalisées remplacent les clés.",
|
|
addDefault: "Ajouter",
|
|
autoDefault: "Auto",
|
|
manualDefault: "Manuel",
|
|
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",
|
|
hits: "Treffers",
|
|
values: "Waarden",
|
|
defaultsApplied: "Defaults",
|
|
source: "Bron",
|
|
file: "Bestand",
|
|
text: "Tekst",
|
|
defaults: "Standaardwaarden",
|
|
defaultsHint: "Mogelijke standaardwaarden worden herkend; eigen waarden overschrijven keys.",
|
|
addDefault: "Waarde toevoegen",
|
|
autoDefault: "Auto",
|
|
manualDefault: "Handmatig",
|
|
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 : "en";
|
|
}
|
|
|
|
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.filter((entry) => entry.key && typeof entry.value === "string" && canPersistDefault(entry.key)) : initialDefaults;
|
|
} catch {
|
|
return initialDefaults;
|
|
}
|
|
}
|
|
|
|
function canPersistDefault(key: string): boolean {
|
|
const signal = normalizeKey(key);
|
|
return !["PASSWORD", "SECRET", "TOKEN", "PRIVATE_KEY", "API_KEY", "ACCESS_KEY"].some((marker) => signal.includes(marker));
|
|
}
|
|
|
|
function readIgnoredAutoDefaults(): string[] {
|
|
const stored = localStorage.getItem("envhelper-ignored-auto-defaults");
|
|
if (!stored) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(stored) as string[];
|
|
return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function readTheme(): ThemeMode {
|
|
const stored = localStorage.getItem("envhelper-theme") as ThemeMode | null;
|
|
return stored && themeLabels.includes(stored) ? stored : "system";
|
|
}
|
|
|
|
function inferAutomaticDefaults(input: string, manualDefaults: EnvDefault[], ignoredKeys: string[]): EnvDefault[] {
|
|
const manualKeys = new Set(manualDefaults.map((entry) => normalizeKey(entry.key)).filter(Boolean));
|
|
const ignored = new Set(ignoredKeys.map(normalizeKey).filter(Boolean));
|
|
const defaults = new Map<string, EnvDefault>();
|
|
|
|
for (const line of input.split(/\r?\n/)) {
|
|
const parsed = parseEnvAssignment(line);
|
|
if (!parsed) {
|
|
continue;
|
|
}
|
|
|
|
const normalizedKey = normalizeKey(parsed.key);
|
|
if (!normalizedKey || manualKeys.has(normalizedKey) || ignored.has(normalizedKey)) {
|
|
continue;
|
|
}
|
|
|
|
const value = inferDefaultValue(parsed.key, parsed.value);
|
|
if (value) {
|
|
defaults.set(normalizedKey, { key: parsed.key, value });
|
|
}
|
|
}
|
|
|
|
return [...defaults.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
}
|
|
|
|
function parseEnvAssignment(line: string): { key: string; value: string } | null {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith("#")) {
|
|
return null;
|
|
}
|
|
|
|
const normalized = trimmed.startsWith("export ") ? trimmed.slice(7).trimStart() : trimmed;
|
|
const equalsIndex = normalized.indexOf("=");
|
|
if (equalsIndex <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
key: normalized.slice(0, equalsIndex).trim(),
|
|
value: normalized.slice(equalsIndex + 1).trim()
|
|
};
|
|
}
|
|
|
|
function inferDefaultValue(key: string, value: string): string | null {
|
|
const signal = normalizeKey(key);
|
|
const hasPlaceholder = /CHANGE_ME[A-Z0-9_]*/i.test(value);
|
|
const isEmpty = value.length === 0;
|
|
const shouldFillGeneric = hasPlaceholder || isEmpty;
|
|
|
|
if (signal === "APP_ENV" || signal === "RAILS_ENV") {
|
|
return "production";
|
|
}
|
|
|
|
if (signal === "NODE_ENV") {
|
|
return "production";
|
|
}
|
|
|
|
if (signal.includes("LOG_LEVEL")) {
|
|
return "info";
|
|
}
|
|
|
|
if (signal.includes("TELEMETRY_ENABLED") || signal.includes("PROMETHEUS_METRICS") || signal.includes("METRICS_ENABLED")) {
|
|
return "false";
|
|
}
|
|
|
|
if (signal.includes("CORS_CREDENTIALS") || signal.includes("USE_SSL") || signal.includes("REQUIRE_SSL")) {
|
|
return "true";
|
|
}
|
|
|
|
if (signal.includes("BOOTSTRAP_ADMIN_EMAIL") || signal.includes("ADMIN_EMAIL")) {
|
|
return "admin@example.local";
|
|
}
|
|
|
|
if (signal.includes("SUPPORT_EMAIL")) {
|
|
return "support@example.local";
|
|
}
|
|
|
|
if (signal.includes("CONTACT_EMAIL")) {
|
|
return "contact@example.local";
|
|
}
|
|
|
|
if (shouldFillGeneric && signal.endsWith("EMAIL")) {
|
|
return "admin@example.local";
|
|
}
|
|
|
|
if (shouldFillGeneric && (signal.includes("BASE_URL") || signal.endsWith("_URL") || signal.endsWith("_URI") || signal.includes("ORIGIN") || signal.includes("SITE_URL"))) {
|
|
return "https://example.local";
|
|
}
|
|
|
|
if (shouldFillGeneric && signal.includes("CORS_ORIGIN")) {
|
|
return "https://example.local";
|
|
}
|
|
|
|
if (shouldFillGeneric && signal.includes("SMTP_HOST")) {
|
|
return "smtp.example.local";
|
|
}
|
|
|
|
if (shouldFillGeneric && signal.includes("SMTP_PORT")) {
|
|
return "587";
|
|
}
|
|
|
|
if (shouldFillGeneric && signal.includes("SMTP_USER")) {
|
|
return "smtp-user";
|
|
}
|
|
|
|
if (shouldFillGeneric && (signal.includes("SMTP_FROM") || signal.includes("MAIL_FROM") || signal.includes("SENDER_EMAIL"))) {
|
|
return "noreply@example.local";
|
|
}
|
|
|
|
if (shouldFillGeneric && (signal.includes("S3_BUCKET") || signal.includes("BUCKET_NAME"))) {
|
|
return "app-bucket";
|
|
}
|
|
|
|
if (shouldFillGeneric && (signal.includes("S3_REGION") || signal.includes("AWS_REGION") || signal.includes("SQS_REGION"))) {
|
|
return "eu-central-1";
|
|
}
|
|
|
|
if (shouldFillGeneric && signal.includes("S3_ENDPOINT")) {
|
|
return "https://s3.example.local";
|
|
}
|
|
|
|
if (shouldFillGeneric && signal.includes("RABBITMQ_URI")) {
|
|
return "amqp://rabbitmq:5672";
|
|
}
|
|
|
|
if (shouldFillGeneric && signal.includes("REDIS_URL")) {
|
|
return "redis://redis:6379/0";
|
|
}
|
|
|
|
if (shouldFillGeneric && signal.includes("PORT")) {
|
|
if (signal.includes("POSTGRES")) {
|
|
return "5432";
|
|
}
|
|
|
|
if (signal.includes("REDIS")) {
|
|
return "6379";
|
|
}
|
|
|
|
if (signal.includes("SMTP")) {
|
|
return "587";
|
|
}
|
|
|
|
if (signal.includes("S3") || signal.includes("MINIO")) {
|
|
return "9000";
|
|
}
|
|
|
|
return "3000";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function normalizeKey(key: string): string {
|
|
return key.trim().toUpperCase();
|
|
}
|
|
|
|
function addUnique(values: string[], value: string): string[] {
|
|
const normalized = normalizeKey(value);
|
|
return values.some((entry) => normalizeKey(entry) === normalized) ? values : [...values, value];
|
|
}
|
|
|
|
function openFileWithBrowserPicker(): Promise<{ name: string; content: string } | null> {
|
|
return new Promise((resolve) => {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = ".env,.txt,text/plain";
|
|
|
|
input.addEventListener(
|
|
"change",
|
|
async () => {
|
|
const file = input.files?.[0];
|
|
if (!file) {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
resolve({
|
|
name: file.name,
|
|
content: await file.text()
|
|
});
|
|
},
|
|
{ once: true }
|
|
);
|
|
|
|
input.click();
|
|
});
|
|
}
|
|
|
|
function saveFileWithBrowserDownload(content: string) {
|
|
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
anchor.href = url;
|
|
anchor.download = ".env";
|
|
anchor.style.display = "none";
|
|
|
|
document.body.append(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
export default function App() {
|
|
const [input, setInput] = useState("");
|
|
const [loadedPath, setLoadedPath] = useState<string | null>(null);
|
|
const [copyLabel, setCopyLabel] = useState<"copy" | "copied">("copy");
|
|
const [generation, setGeneration] = useState(0);
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const [themeMode, setThemeMode] = useState<ThemeMode>(readTheme);
|
|
const [language, setLanguage] = useState<Language>(detectLanguage);
|
|
const [manualDefaults, setManualDefaults] = useState<EnvDefault[]>(readDefaults);
|
|
const [ignoredAutoDefaults, setIgnoredAutoDefaults] = useState<string[]>(readIgnoredAutoDefaults);
|
|
const t = translations[language];
|
|
const automaticDefaults = useMemo(() => inferAutomaticDefaults(input, manualDefaults, ignoredAutoDefaults), [input, manualDefaults, ignoredAutoDefaults]);
|
|
const defaults = useMemo<DefaultRow[]>(
|
|
() => [
|
|
...automaticDefaults.map((entry) => ({ ...entry, source: "auto" as const })),
|
|
...manualDefaults.map((entry, index) => ({ ...entry, index, source: "manual" as const }))
|
|
],
|
|
[automaticDefaults, manualDefaults]
|
|
);
|
|
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(manualDefaults.filter((entry) => canPersistDefault(entry.key))));
|
|
}, [manualDefaults]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("envhelper-ignored-auto-defaults", JSON.stringify(ignoredAutoDefaults));
|
|
}, [ignoredAutoDefaults]);
|
|
|
|
async function openFile() {
|
|
try {
|
|
if (window.envHelper?.openFile) {
|
|
const file = await window.envHelper.openFile();
|
|
if (file) {
|
|
setInput(file.content);
|
|
setLoadedPath(file.path);
|
|
}
|
|
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
console.warn("Native file dialog failed, falling back to browser file input.", error);
|
|
}
|
|
|
|
const file = await openFileWithBrowserPicker();
|
|
if (file) {
|
|
setInput(file.content);
|
|
setLoadedPath(file.name);
|
|
}
|
|
}
|
|
|
|
async function saveFile() {
|
|
try {
|
|
if (window.envHelper?.saveFile) {
|
|
await window.envHelper.saveFile(result.output);
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
console.warn("Native save dialog failed, falling back to browser download.", error);
|
|
}
|
|
|
|
saveFileWithBrowserDownload(result.output);
|
|
}
|
|
|
|
async function copyOutput() {
|
|
await navigator.clipboard.writeText(result.output);
|
|
setCopyLabel("copied");
|
|
window.setTimeout(() => setCopyLabel("copy"), 1400);
|
|
}
|
|
|
|
function updateDefault(row: DefaultRow, field: keyof EnvDefault, value: string) {
|
|
if (row.source === "auto") {
|
|
setIgnoredAutoDefaults((current) => addUnique(current, row.key));
|
|
setManualDefaults((current) => [...current, { key: field === "key" ? value : row.key, value: field === "value" ? value : row.value }]);
|
|
return;
|
|
}
|
|
|
|
if (row.index === undefined) {
|
|
return;
|
|
}
|
|
|
|
setManualDefaults((current) => current.map((entry, entryIndex) => (entryIndex === row.index ? { ...entry, [field]: value } : entry)));
|
|
}
|
|
|
|
function addDefault() {
|
|
setManualDefaults((current) => [...current, { key: "", value: "" }]);
|
|
}
|
|
|
|
function removeDefault(row: DefaultRow) {
|
|
if (row.source === "auto") {
|
|
setIgnoredAutoDefaults((current) => addUnique(current, row.key));
|
|
return;
|
|
}
|
|
|
|
if (row.index === undefined) {
|
|
return;
|
|
}
|
|
|
|
setManualDefaults((current) => current.filter((_, entryIndex) => entryIndex !== row.index));
|
|
}
|
|
|
|
return (
|
|
<main className="appShell">
|
|
<header className="toolbar">
|
|
<div className="brand">
|
|
<div className="brandMark">EH</div>
|
|
<div>
|
|
<h1>EnvHelper</h1>
|
|
<p>{t.appSubtitle}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="toolbarActions">
|
|
<button className="secondary" onClick={openFile} type="button">
|
|
<FileInput size={17} /> {t.loadFile}
|
|
</button>
|
|
<button className="secondary" onClick={saveFile} type="button" disabled={!result.output}>
|
|
<FileDown size={17} /> {t.saveFile}
|
|
</button>
|
|
<div className="segmented" aria-label={t.theme}>
|
|
{themeLabels.map((mode) => (
|
|
<button
|
|
className={themeMode === mode ? "active" : ""}
|
|
key={mode}
|
|
onClick={() => setThemeMode(mode)}
|
|
type="button"
|
|
title={t[mode]}
|
|
>
|
|
{mode === "system" ? <SunMoon size={15} /> : t[mode]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button className="iconButton" onClick={() => setSettingsOpen(true)} title={t.settings} type="button">
|
|
<Settings size={18} />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<section className="statusStrip">
|
|
<Stat label={t.hits} value={String(result.changedCount)} />
|
|
<Stat label={t.values} value={String(result.replacements.length)} />
|
|
<Stat label={t.defaultsApplied} value={String(result.defaultedKeys.length)} />
|
|
<Stat label={t.source} value={loadedPath ? t.file : t.text} />
|
|
</section>
|
|
|
|
<section className="workbench">
|
|
<EditorPanel
|
|
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={`${entry.source}-${entry.key}-${index}`}>
|
|
<input
|
|
aria-label={t.key}
|
|
placeholder={t.key}
|
|
value={entry.key}
|
|
onChange={(event) => updateDefault(entry, "key", event.target.value)}
|
|
/>
|
|
<input
|
|
aria-label={t.value}
|
|
placeholder={t.value}
|
|
value={entry.value}
|
|
onChange={(event) => updateDefault(entry, "value", event.target.value)}
|
|
/>
|
|
<span className={`defaultSource ${entry.source}`}>{entry.source === "auto" ? t.autoDefault : t.manualDefault}</span>
|
|
<button className="iconButton subtle" onClick={() => removeDefault(entry)} title="Remove" type="button">
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button className="secondary fullWidth" onClick={addDefault} type="button">
|
|
<Plus size={17} /> {t.addDefault}
|
|
</button>
|
|
</section>
|
|
|
|
<section className="dataPanel">
|
|
<PanelHeading icon={<ShieldCheck size={18} />} title={t.detected} hint={t.detectedHint} />
|
|
{result.replacements.length === 0 ? (
|
|
<div className="empty">{t.noPlaceholders}</div>
|
|
) : (
|
|
<div className="requirementsTable">
|
|
<div className="tableHeader">
|
|
<span>{t.placeholder}</span>
|
|
<span>{t.keys}</span>
|
|
<span>{t.format}</span>
|
|
</div>
|
|
{result.replacements.map((replacement) => (
|
|
<div className="tableRow" key={replacement.placeholder}>
|
|
<code>{replacement.placeholder}</code>
|
|
<span>{replacement.keys.join(", ")}</span>
|
|
<strong>{replacement.format}</strong>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</section>
|
|
|
|
{settingsOpen ? (
|
|
<aside className="settingsOverlay" aria-label={t.settings}>
|
|
<div className="settingsPanel">
|
|
<div className="settingsHeader">
|
|
<div>
|
|
<h2>{t.settings}</h2>
|
|
<p>{t.appSubtitle}</p>
|
|
</div>
|
|
<button className="iconButton" onClick={() => setSettingsOpen(false)} title="Close" type="button">
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="settingsGroup">
|
|
<label>
|
|
<Languages size={18} /> {t.language}
|
|
</label>
|
|
<div className="languageGrid">
|
|
{(Object.keys(languageNames) as Language[]).map((code) => (
|
|
<button className={language === code ? "selected" : ""} key={code} onClick={() => setLanguage(code)} type="button">
|
|
{languageNames[code]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="settingsMeta">
|
|
<span>{t.version}: {packageInfo.version}</span>
|
|
<span>{t.author}: {packageInfo.author}</span>
|
|
<span>{t.repository}: MrSphay/envHelper</span>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
) : null}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function Stat({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="statItem">
|
|
<span>{value}</span>
|
|
<p>{label}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EditorPanel({ actions, children, hint, title }: { actions?: ReactNode; children: ReactNode; hint: string; title: string }) {
|
|
return (
|
|
<section className="editorPanel">
|
|
<div className="panelHeader">
|
|
<div>
|
|
<h2>{title}</h2>
|
|
<p>{hint}</p>
|
|
</div>
|
|
{actions ? <div className="buttonRow">{actions}</div> : null}
|
|
</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>
|
|
);
|
|
}
|