Build EnvHelper desktop app
Some checks failed
Build Windows App / build-windows (push) Has been cancelled
Some checks failed
Build Windows App / build-windows (push) Has been cancelled
This commit is contained in:
150
src/App.tsx
Normal file
150
src/App.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Check, Clipboard, FileDown, FileInput, RefreshCcw, ShieldCheck, Sparkles } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { transformEnv } from "./env";
|
||||
|
||||
const sampleEnv = `APP_IMAGE=git.wilkensxl.de/mrsphay/hilden-directory-gateway:latest
|
||||
APP_PORT=3000
|
||||
|
||||
NODE_ENV=production
|
||||
PUBLIC_BASE_URL=https://gateway.example.local
|
||||
TRUSTED_IFRAME_ANCESTORS=https://www.hilden.de
|
||||
|
||||
DATABASE_URL=postgresql://hilden_app:CHANGE_ME_POSTGRES_PASSWORD@postgres:5432/hilden_directory
|
||||
POSTGRES_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD
|
||||
|
||||
SESSION_SECRET=CHANGE_ME_AT_LEAST_32_RANDOM_CHARACTERS
|
||||
ENCRYPTION_KEY_BASE64=CHANGE_ME_32_RANDOM_BYTES_AS_BASE64
|
||||
|
||||
BOOTSTRAP_ADMIN_EMAIL=admin@example.local
|
||||
BOOTSTRAP_ADMIN_PASSWORD=CHANGE_ME_LONG_INITIAL_ADMIN_PASSWORD`;
|
||||
|
||||
export default function App() {
|
||||
const [input, setInput] = useState(sampleEnv);
|
||||
const [loadedPath, setLoadedPath] = useState<string | null>(null);
|
||||
const [copyLabel, setCopyLabel] = useState("Kopieren");
|
||||
const [generation, setGeneration] = useState(0);
|
||||
const result = useMemo(() => transformEnv(input), [input, generation]);
|
||||
|
||||
async function openFile() {
|
||||
const file = await window.envHelper?.openFile();
|
||||
if (file) {
|
||||
setInput(file.content);
|
||||
setLoadedPath(file.path);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile() {
|
||||
await window.envHelper?.saveFile(result.output);
|
||||
}
|
||||
|
||||
async function copyOutput() {
|
||||
await navigator.clipboard.writeText(result.output);
|
||||
setCopyLabel("Kopiert");
|
||||
window.setTimeout(() => setCopyLabel("Kopieren"), 1400);
|
||||
}
|
||||
|
||||
function regenerate() {
|
||||
setGeneration((current) => current + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<section className="hero">
|
||||
<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>
|
||||
</div>
|
||||
<div className="heroActions">
|
||||
<button className="secondary" onClick={openFile} type="button">
|
||||
<FileInput size={18} /> Datei laden
|
||||
</button>
|
||||
<button className="secondary" onClick={saveFile} type="button" disabled={!result.output}>
|
||||
<FileDown size={18} /> .env speichern
|
||||
</button>
|
||||
</div>
|
||||
</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} />
|
||||
</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 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>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
220
src/env.ts
Normal file
220
src/env.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
export interface Replacement {
|
||||
placeholder: string;
|
||||
value: string;
|
||||
format: string;
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
export interface TransformResult {
|
||||
output: string;
|
||||
replacements: Replacement[];
|
||||
changedCount: number;
|
||||
}
|
||||
|
||||
interface PlaceholderHit {
|
||||
key: string;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
const changeMePattern = /CHANGE_ME[A-Z0-9_]*/g;
|
||||
|
||||
export function transformEnv(input: string): TransformResult {
|
||||
const hits = collectHits(input);
|
||||
const generated = new Map<string, Replacement>();
|
||||
|
||||
for (const hit of hits) {
|
||||
if (!generated.has(hit.placeholder)) {
|
||||
const requirement = inferRequirement(hit.key, hit.placeholder);
|
||||
generated.set(hit.placeholder, {
|
||||
placeholder: hit.placeholder,
|
||||
value: generateValue(requirement),
|
||||
format: requirement.label,
|
||||
keys: []
|
||||
});
|
||||
}
|
||||
|
||||
const replacement = generated.get(hit.placeholder);
|
||||
if (replacement && !replacement.keys.includes(hit.key)) {
|
||||
replacement.keys.push(hit.key);
|
||||
}
|
||||
}
|
||||
|
||||
let output = input;
|
||||
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);
|
||||
}
|
||||
|
||||
return {
|
||||
output,
|
||||
replacements: [...generated.values()].sort((a, b) => a.placeholder.localeCompare(b.placeholder)),
|
||||
changedCount: hits.length
|
||||
};
|
||||
}
|
||||
|
||||
function collectHits(input: string): PlaceholderHit[] {
|
||||
const hits: PlaceholderHit[] = [];
|
||||
|
||||
for (const line of input.split(/\r?\n/)) {
|
||||
const parsed = parseAssignment(line);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const matches = parsed.value.matchAll(changeMePattern);
|
||||
for (const match of matches) {
|
||||
hits.push({
|
||||
key: parsed.key,
|
||||
placeholder: match[0]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return hits;
|
||||
}
|
||||
|
||||
function parseAssignment(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()
|
||||
};
|
||||
}
|
||||
|
||||
type Requirement =
|
||||
| { type: "base64"; bytes: number; label: string }
|
||||
| { type: "hex"; bytes: number; label: string }
|
||||
| { type: "uuid"; label: string }
|
||||
| { type: "integer"; min: number; max: number; label: string }
|
||||
| { type: "url"; label: string }
|
||||
| { type: "email"; label: string }
|
||||
| { 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();
|
||||
|
||||
const byteCount = extractNumberBefore(signal, "RANDOM_BYTES") ?? extractNumberBefore(signal, "BYTES");
|
||||
|
||||
if (signal.includes("BASE64")) {
|
||||
return {
|
||||
type: "base64",
|
||||
bytes: byteCount ?? 32,
|
||||
label: `${byteCount ?? 32} random bytes as Base64`
|
||||
};
|
||||
}
|
||||
|
||||
if (signal.includes("UUID")) {
|
||||
return { type: "uuid", label: "UUID v4" };
|
||||
}
|
||||
|
||||
if (signal.includes("HEX")) {
|
||||
return { type: "hex", bytes: byteCount ?? 32, label: `${byteCount ?? 32} random bytes as hex` };
|
||||
}
|
||||
|
||||
if (signal.includes("PORT")) {
|
||||
return { type: "integer", min: 1024, max: 49151, label: "TCP port number" };
|
||||
}
|
||||
|
||||
if (signal.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;
|
||||
return {
|
||||
type: "password",
|
||||
length,
|
||||
urlSafe: signal.includes("DATABASE") || signal.includes("POSTGRES") || signal.includes("MYSQL"),
|
||||
label: `${length} character strong password`
|
||||
};
|
||||
}
|
||||
|
||||
const minimumLength = extractNumberAfter(signal, "AT_LEAST") ?? 48;
|
||||
if (signal.includes("SECRET") || signal.includes("TOKEN") || signal.includes("KEY")) {
|
||||
return {
|
||||
type: "secret",
|
||||
length: Math.max(minimumLength, 32),
|
||||
label: `${Math.max(minimumLength, 32)} character URL-safe secret`
|
||||
};
|
||||
}
|
||||
|
||||
return { type: "secret", length: 32, label: "32 character URL-safe secret" };
|
||||
}
|
||||
|
||||
function generateValue(requirement: Requirement): string {
|
||||
switch (requirement.type) {
|
||||
case "base64":
|
||||
return bytesToBase64(randomBytes(requirement.bytes));
|
||||
case "hex":
|
||||
return [...randomBytes(requirement.bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
case "uuid":
|
||||
return crypto.randomUUID();
|
||||
case "integer":
|
||||
return String(randomInteger(requirement.min, requirement.max));
|
||||
case "url":
|
||||
return "https://example.local";
|
||||
case "email":
|
||||
return "admin@example.local";
|
||||
case "password":
|
||||
return randomString(
|
||||
requirement.length,
|
||||
requirement.urlSafe
|
||||
? "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789_-"
|
||||
: "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!#$%&*+-=?@_"
|
||||
);
|
||||
case "secret":
|
||||
return randomString(requirement.length, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-");
|
||||
}
|
||||
}
|
||||
|
||||
function randomBytes(length: number): Uint8Array {
|
||||
const bytes = new Uint8Array(length);
|
||||
crypto.getRandomValues(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function randomInteger(min: number, max: number): number {
|
||||
const range = max - min + 1;
|
||||
const array = new Uint32Array(1);
|
||||
crypto.getRandomValues(array);
|
||||
return min + (array[0] % range);
|
||||
}
|
||||
|
||||
function randomString(length: number, alphabet: string): string {
|
||||
const bytes = randomBytes(length);
|
||||
return [...bytes].map((byte) => alphabet[byte % alphabet.length]).join("");
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function extractNumberBefore(signal: string, marker: string): number | null {
|
||||
const match = signal.match(new RegExp(`(\\d+)_${marker}`));
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
function extractNumberAfter(signal: string, marker: string): number | null {
|
||||
const match = signal.match(new RegExp(`${marker}_(\\d+)`));
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
300
src/styles.css
Normal file
300
src/styles.css
Normal file
@@ -0,0 +1,300 @@
|
||||
:root {
|
||||
color: #17211c;
|
||||
background: #f5f5ef;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 940px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(41, 94, 82, 0.12), transparent 36%),
|
||||
linear-gradient(315deg, rgba(190, 78, 61, 0.1), transparent 34%),
|
||||
#f5f5ef;
|
||||
}
|
||||
|
||||
button,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
align-items: center;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-weight: 700;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
min-height: 42px;
|
||||
padding: 0 16px;
|
||||
transition:
|
||||
transform 140ms ease,
|
||||
box-shadow 140ms ease,
|
||||
background 140ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
margin: 0 auto;
|
||||
max-width: 1420px;
|
||||
min-height: 100vh;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
justify-content: space-between;
|
||||
padding: 10px 2px 4px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
align-items: center;
|
||||
color: #2d6f62;
|
||||
display: flex;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
gap: 7px;
|
||||
letter-spacing: 0;
|
||||
margin: 0 0 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #13201b;
|
||||
font-size: 2.8rem;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.02;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.subline {
|
||||
color: #53635d;
|
||||
font-size: 1.04rem;
|
||||
line-height: 1.5;
|
||||
margin-top: 12px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.heroActions,
|
||||
.buttonRow {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: #1f5d53;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 10px 24px rgba(31, 93, 83, 0.18);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: #ffffff;
|
||||
color: #1f332d;
|
||||
box-shadow: inset 0 0 0 1px #d9ded5;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
background: #eef2ec;
|
||||
color: #26372f;
|
||||
min-width: 42px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stats div {
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border: 1px solid #dde3da;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.stats span {
|
||||
color: #9b3f31;
|
||||
display: block;
|
||||
font-size: 1.35rem;
|
||||
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 {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 18px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
min-height: 430px;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.replacementPanel {
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
border: 1px solid #dbe2d8;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 20px 54px rgba(54, 67, 61, 0.1);
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e2e6de;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panelHeader.compact {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
background: #101815;
|
||||
border: 0;
|
||||
color: #e7eee8;
|
||||
flex: 1;
|
||||
line-height: 1.55;
|
||||
min-height: 360px;
|
||||
outline: none;
|
||||
padding: 18px;
|
||||
resize: none;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
textarea::selection {
|
||||
background: #be4e3d;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.replacementPanel {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.replacementGrid {
|
||||
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;
|
||||
}
|
||||
|
||||
.replacement strong {
|
||||
color: #26372f;
|
||||
display: block;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.35;
|
||||
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;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #66736d;
|
||||
padding: 0 16px 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shell {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.heroActions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
.replacementGrid,
|
||||
.stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
13
src/vite-env.d.ts
vendored
Normal file
13
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface EnvHelperFileResult {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
envHelper?: {
|
||||
openFile: () => Promise<EnvHelperFileResult | null>;
|
||||
saveFile: (content: string) => Promise<string | null>;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user