Merge branch 'phase/002-design-layout'
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
|
||||
export const metadata: Metadata = { title: "Einstellungen" };
|
||||
|
||||
export default function Einstellungen() {
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Einstellungen"
|
||||
untertitel="Deployment-Key, eigene Vorlagen, Benachrichtigungen"
|
||||
phase={3}
|
||||
issue="#019"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
|
||||
export const metadata: Metadata = { title: "Hostgruppen" };
|
||||
|
||||
export default function Hostgruppen() {
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Hostgruppen"
|
||||
untertitel="Hosts bündeln, um Schlüssel gemeinsam zuzuweisen"
|
||||
phase={1}
|
||||
issue="#008"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
|
||||
export const metadata: Metadata = { title: "Hosts" };
|
||||
|
||||
export default function Hosts() {
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Hosts"
|
||||
untertitel="Verwaltete Server, Host-Keys und Erreichbarkeit"
|
||||
phase={1}
|
||||
issue="#008"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { HuellenBenutzer } from "@/components/huelle/typen";
|
||||
|
||||
/**
|
||||
* Liefert den Benutzer, den die Seitenleiste anzeigt.
|
||||
*
|
||||
* TODO (#004): durch die echte Sitzung ersetzen (Benutzer + Rolle aus der Datenbank,
|
||||
* Herkunft „authentik“ oder „Notfall-Admin“) und ohne Sitzung zur Anmeldung umleiten.
|
||||
* Bis dahin ein fester Beispielbenutzer, damit der Prototyp ohne Anmeldung läuft.
|
||||
* async schon jetzt, damit sich die Aufrufer beim Umstieg nicht ändern müssen.
|
||||
*/
|
||||
export async function huellenBenutzerErmitteln(): Promise<HuellenBenutzer> {
|
||||
return { name: "Florian Wilde", rolle: "ADMIN", herkunft: "authentik" };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Huelle } from "@/components/huelle/Huelle";
|
||||
import { huellenBenutzerErmitteln } from "./huellen-benutzer";
|
||||
|
||||
/** Alle angemeldeten Seiten teilen sich die Hülle mit Seitenleiste (#002). */
|
||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
const benutzer = await huellenBenutzerErmitteln();
|
||||
return <Huelle benutzer={benutzer}>{children}</Huelle>;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { ArrowRight, Plus, RefreshCw, Route } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
abweichungen,
|
||||
BEISPIEL_JETZT,
|
||||
baldAblaufend,
|
||||
kennzahlen,
|
||||
letzteAktionen,
|
||||
letzterAbgleich,
|
||||
type AktionsArt,
|
||||
} from "@/beispiel/uebersicht";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Karte } from "@/components/ui/Karte";
|
||||
import { Kennzahl } from "@/components/ui/Kennzahl";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { Statuspunkt } from "@/components/ui/Statuspunkt";
|
||||
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||
import { datum, datumZeit, relativ, tageBis, uhrzeit } from "@/lib/format";
|
||||
|
||||
export const metadata: Metadata = { title: "Übersicht" };
|
||||
|
||||
const AKTION: Record<AktionsArt, { text: string; farbe: string }> = {
|
||||
ausgerollt: { text: "Ausgerollt", farbe: "text-ok" },
|
||||
widerrufen: { text: "Widerrufen", farbe: "text-danger" },
|
||||
erstellt: { text: "Erstellt", farbe: "text-text" },
|
||||
abgleich: { text: "Abgleich", farbe: "text-info" },
|
||||
rotiert: { text: "Rotiert", farbe: "text-accent" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Prototyp der Übersicht mit Beispieldaten (#002). Die echte Übersicht mit Lesemodellen
|
||||
* folgt in #026; Aufbau und Komponenten bleiben.
|
||||
*/
|
||||
export default function Uebersicht() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
titel="Übersicht"
|
||||
badges={<Badge variante="messing">Prototyp · Beispieldaten</Badge>}
|
||||
untertitel={
|
||||
<>
|
||||
Letzter Abgleich {relativ(letzterAbgleich, BEISPIEL_JETZT)} ·{" "}
|
||||
<time dateTime={letzterAbgleich} className="font-mono text-[13px]">
|
||||
{datumZeit(letzterAbgleich)}
|
||||
</time>
|
||||
</>
|
||||
}
|
||||
aktionen={
|
||||
<>
|
||||
<Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button>
|
||||
<Button href="/zuweisungen" icon={<Route className="size-4" />}>
|
||||
Host-zu-Host-Assistent
|
||||
</Button>
|
||||
<Button
|
||||
href="/schluessel"
|
||||
variante="primaer"
|
||||
icon={<Plus className="size-4" strokeWidth={2.4} />}
|
||||
>
|
||||
Neuer Schlüssel
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-5 xl:grid-cols-4">
|
||||
<Kennzahl
|
||||
titel="Hosts"
|
||||
wert={kennzahlen.hosts.wert}
|
||||
hinweis={kennzahlen.hosts.hinweis}
|
||||
href="/hosts"
|
||||
/>
|
||||
<Kennzahl
|
||||
titel="Schlüssel"
|
||||
wert={kennzahlen.schluessel.wert}
|
||||
hinweis={kennzahlen.schluessel.hinweis}
|
||||
href="/schluessel"
|
||||
/>
|
||||
<Kennzahl
|
||||
titel="Abweichungen"
|
||||
variante="gefahr"
|
||||
wert={kennzahlen.abweichungen.wert}
|
||||
hinweis={kennzahlen.abweichungen.hinweis}
|
||||
href="/hosts"
|
||||
/>
|
||||
<Kennzahl
|
||||
titel="Läuft in 30 Tagen ab"
|
||||
variante="messing"
|
||||
wert={kennzahlen.ablaufend.wert}
|
||||
hinweis={kennzahlen.ablaufend.hinweis}
|
||||
href="/schluessel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)]">
|
||||
<Karte titel="Hosts mit Abweichung" untertitel="Ergebnis des letzten Abgleichs">
|
||||
<Tabelle
|
||||
beschriftung="Hosts mit Abweichung"
|
||||
spalten={[
|
||||
{ titel: "Host" },
|
||||
{ titel: "Benutzer" },
|
||||
{ titel: "Befund" },
|
||||
{ titel: "Aktion", rechts: true, versteckt: true },
|
||||
]}
|
||||
>
|
||||
{abweichungen.map((a) => (
|
||||
<TabellenZeile key={`${a.host}-${a.benutzer}`}>
|
||||
<TabellenZelle mono className="whitespace-nowrap">
|
||||
{a.host}
|
||||
</TabellenZelle>
|
||||
<TabellenZelle mono>{a.benutzer}</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<Statuspunkt status={a.status} text={a.befund} />
|
||||
</TabellenZelle>
|
||||
<TabellenZelle rechts>
|
||||
<Link
|
||||
href="/hosts"
|
||||
className="text-accent -my-2 inline-flex min-h-11 items-center px-1 font-medium hover:underline"
|
||||
>
|
||||
Prüfen<span className="sr-only"> – {a.host}</span>
|
||||
</Link>
|
||||
</TabellenZelle>
|
||||
</TabellenZeile>
|
||||
))}
|
||||
</Tabelle>
|
||||
</Karte>
|
||||
|
||||
<Karte titel="Bald ablaufend" untertitel="Schlüssel mit Ablaufdatum in den nächsten 30 Tagen">
|
||||
<ul className="flex flex-col">
|
||||
{baldAblaufend.map((s) => {
|
||||
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
|
||||
return (
|
||||
<li key={s.zweck} className="border-border border-b last:border-b-0">
|
||||
<Link
|
||||
href="/schluessel/beispiel"
|
||||
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="text-text block truncate text-[14.5px] font-medium">{s.zweck}</span>
|
||||
<span className="text-muted block truncate text-[13px]">
|
||||
{s.person} · <span className="font-mono">{s.typ}</span>
|
||||
</span>
|
||||
</span>
|
||||
<Badge variante="messing" className="shrink-0">
|
||||
<time dateTime={s.ablauf}>{tage === 1 ? "morgen" : `in ${tage} Tagen`}</time>
|
||||
</Badge>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Karte>
|
||||
</div>
|
||||
|
||||
<Karte
|
||||
titel="Letzte Aktionen"
|
||||
aktionen={
|
||||
<Link
|
||||
href="/protokoll"
|
||||
className="text-accent inline-flex min-h-11 items-center gap-1.5 text-[14px] font-medium hover:underline"
|
||||
>
|
||||
Zum Audit-Log <ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<ol className="flex flex-col">
|
||||
{letzteAktionen.map((a) => (
|
||||
<li
|
||||
key={a.zeit}
|
||||
className="border-border grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 border-b py-3 text-[14px] last:border-b-0 md:grid-cols-[8.5rem_6.5rem_1fr_auto] md:items-baseline"
|
||||
>
|
||||
<time dateTime={a.zeit} className="text-muted font-mono text-[12.5px] whitespace-nowrap">
|
||||
{datum(a.zeit)} {uhrzeit(a.zeit)}
|
||||
</time>
|
||||
<span className={`${AKTION[a.art].farbe} font-medium md:order-none`}>{AKTION[a.art].text}</span>
|
||||
<span className="text-text col-span-2 min-w-0 md:col-span-1">{a.beschreibung}</span>
|
||||
<span className="text-muted col-span-2 text-[13px] md:col-span-1 md:text-right">
|
||||
{a.benutzer}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Karte>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
|
||||
export const metadata: Metadata = { title: "Personen" };
|
||||
|
||||
export default function Personen() {
|
||||
return (
|
||||
<Platzhalterseite titel="Personen" untertitel="Wem welche Schlüssel gehören" phase={2} issue="#013" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
|
||||
export const metadata: Metadata = { title: "Audit-Log" };
|
||||
|
||||
export default function Protokoll() {
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Audit-Log"
|
||||
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen"
|
||||
phase={5}
|
||||
issue="#027"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Ban, Check, Lock, PlugZap, Plus, RotateCw } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
beispielSchluessel as s,
|
||||
dryRunDatei,
|
||||
dryRunDiff,
|
||||
privatAusgerollt,
|
||||
publicAusgerollt,
|
||||
zusatzaktionen,
|
||||
type Ausrollung,
|
||||
} from "@/beispiel/schluessel";
|
||||
import { BEISPIEL_JETZT } from "@/beispiel/uebersicht";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { DiffBox } from "@/components/ui/DiffBox";
|
||||
import { InnereBox, Karte } from "@/components/ui/Karte";
|
||||
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { Statuspunkt } from "@/components/ui/Statuspunkt";
|
||||
import { datum, datumZeit, tageBis } from "@/lib/format";
|
||||
|
||||
export const metadata: Metadata = { title: `${s.zweck} (Beispiel)` };
|
||||
|
||||
/** Prototyp des Schlüsseldetails mit Beispieldaten (#002); echte Daten ab #012/#016/#017. */
|
||||
export default function SchluesselBeispiel() {
|
||||
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: s.zweck }]} />}
|
||||
titel={s.zweck}
|
||||
badges={
|
||||
<>
|
||||
<Badge mono>{s.typ}</Badge>
|
||||
<Badge>{s.kategorie}</Badge>
|
||||
<Badge>Vorlage: {s.vorlage}</Badge>
|
||||
<Badge variante="messing">
|
||||
<span>
|
||||
läuft ab am <time dateTime={s.ablauf}>{datum(s.ablauf)}</time> · in {tage} Tagen
|
||||
</span>
|
||||
</Badge>
|
||||
<Badge variante="neutral" className="text-muted">
|
||||
Beispieldaten
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
aktionen={
|
||||
<>
|
||||
<Button icon={<PlugZap className="size-4" />}>Verbindung testen</Button>
|
||||
<Button icon={<RotateCw className="size-4" />}>Rotieren</Button>
|
||||
<Button variante="gefahr" icon={<Ban className="size-4" />}>
|
||||
Widerrufen
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Karte as="div" innen="ohne">
|
||||
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
|
||||
<InfoFeld titel="Fingerprint">
|
||||
<MonoKopieren wert={s.fingerprint} beschriftung="Fingerprint" umbrechen className="-my-[11px]" />
|
||||
</InfoFeld>
|
||||
<InfoFeld titel="Erstellt">
|
||||
<time dateTime={s.erstellt} className="font-mono text-[13px]">
|
||||
{datumZeit(s.erstellt)}
|
||||
</time>
|
||||
<span className="text-muted block text-[13px]">von {s.erstelltVon}</span>
|
||||
</InfoFeld>
|
||||
<InfoFeld titel="Private Key">
|
||||
<span className="inline-flex flex-wrap items-center gap-x-1.5 text-[14px]">
|
||||
<Lock className="text-ok size-3.5" aria-hidden />
|
||||
verschlüsselt gespeichert ·
|
||||
<button
|
||||
type="button"
|
||||
className="text-accent -my-3 inline-flex min-h-11 items-center font-medium hover:underline"
|
||||
>
|
||||
anzeigen (Re-Auth)
|
||||
</button>
|
||||
</span>
|
||||
</InfoFeld>
|
||||
</dl>
|
||||
</Karte>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<AusrollKarte
|
||||
titel="Private Key ausgerollt auf"
|
||||
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
|
||||
eintraege={privatAusgerollt}
|
||||
/>
|
||||
<AusrollKarte
|
||||
titel="Public Key ausgerollt auf"
|
||||
untertitel="Konten, die diesen Schlüssel annehmen"
|
||||
eintraege={publicAusgerollt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Karte
|
||||
titel="Dry-Run-Vorschau"
|
||||
untertitel={
|
||||
<>
|
||||
Abgleich für <span className="font-mono">backup@srv-pve02</span> – noch nichts geschrieben
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DiffBox datei={dryRunDatei} zeilen={dryRunDiff} />
|
||||
<fieldset className="mt-5">
|
||||
<legend className="text-muted mb-1 text-[13px] font-medium">Zusätzlich ausführen</legend>
|
||||
<ul className="flex flex-col">
|
||||
{zusatzaktionen.map((z) => (
|
||||
<li key={z.id}>
|
||||
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-[14.5px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={z.id}
|
||||
defaultChecked={z.an}
|
||||
className="accent-accent size-[18px] shrink-0"
|
||||
/>
|
||||
<span>{z.text}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</fieldset>
|
||||
<div className="border-border mt-5 flex flex-wrap gap-2.5 border-t pt-5">
|
||||
<Button variante="primaer" icon={<Check className="size-4" strokeWidth={2.4} />}>
|
||||
Änderung anwenden
|
||||
</Button>
|
||||
<Button>Verwerfen</Button>
|
||||
</div>
|
||||
</Karte>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0 px-5 py-4 sm:px-6">
|
||||
<dt className="text-muted mb-1.5 text-[13px] font-medium">{titel}</dt>
|
||||
<dd className="min-w-0">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AusrollKarte({
|
||||
titel,
|
||||
untertitel,
|
||||
eintraege,
|
||||
}: {
|
||||
titel: string;
|
||||
untertitel: string;
|
||||
eintraege: Ausrollung[];
|
||||
}) {
|
||||
return (
|
||||
<Karte
|
||||
titel={titel}
|
||||
untertitel={untertitel}
|
||||
aktionen={
|
||||
<Button klein icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||
Ausrollen
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{eintraege.map((e) => (
|
||||
<li key={`${e.ziel}${e.pfad}`}>
|
||||
<InnereBox className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-text font-mono text-[13.5px] font-medium break-all">{e.ziel}</p>
|
||||
<p className="text-muted mt-1 font-mono text-[12.5px] break-all">{e.pfad}</p>
|
||||
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
|
||||
</div>
|
||||
<Statuspunkt status={e.status} text={e.statusText} className="shrink-0 sm:pt-0.5" />
|
||||
</InnereBox>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Karte>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
|
||||
export const metadata: Metadata = { title: "Schlüssel" };
|
||||
|
||||
export default function Schluessel() {
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Platzhalterseite
|
||||
titel="Schlüssel"
|
||||
untertitel="SSH-Schlüssel erzeugen, importieren und ausrollen"
|
||||
phase={2}
|
||||
issue="#012"
|
||||
/>
|
||||
<p className="text-muted text-[14px]">
|
||||
Prototyp ansehen:{" "}
|
||||
<Link href="/schluessel/beispiel" className="text-accent font-medium hover:underline">
|
||||
Schlüsseldetail mit Beispieldaten
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
|
||||
export const metadata: Metadata = { title: "Vorlagen" };
|
||||
|
||||
export default function Vorlagen() {
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Vorlagen"
|
||||
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup"
|
||||
phase={3}
|
||||
issue="#019"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
|
||||
export const metadata: Metadata = { title: "Zuweisungen" };
|
||||
|
||||
export default function Zuweisungen() {
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Zuweisungen"
|
||||
untertitel="Welcher Schlüssel auf welchem Host-Konto gilt"
|
||||
phase={2}
|
||||
issue="#016"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@@ -1 +1,104 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Design „Tresor“ (#002): Graphit-Flächen, eine Markenfarbe (Messing), Monospace für alles
|
||||
* Technische. Die Werte stehen als CSS-Variablen in :root, damit auch Inline-SVGs und
|
||||
* Stile ausserhalb von Tailwind dieselben Tokens nutzen; @theme reicht sie an Tailwind
|
||||
* weiter (bg-surface, text-muted, border-strong …).
|
||||
*
|
||||
* Es gibt bewusst nur den dunklen Modus. Ein heller folgt in einer eigenen Phase (PLAN §5).
|
||||
*/
|
||||
:root {
|
||||
--bg: #0d1013;
|
||||
--sidebar: #11151a;
|
||||
--surface: #151a1f;
|
||||
--raised: #1d232a;
|
||||
--border: #222930;
|
||||
--border-strong: #2a323b;
|
||||
--text: #e8ecef;
|
||||
--text-muted: #8b96a3;
|
||||
--accent: #f0b43c;
|
||||
--accent-soft: #3a2e14;
|
||||
--ok: #3cc9a7;
|
||||
--danger: #f2714b;
|
||||
--danger-soft: #2a1814;
|
||||
--danger-border: #6b3324;
|
||||
--info: #6aa8ff;
|
||||
|
||||
--schrift-display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
|
||||
--schrift-text: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
--schrift-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
|
||||
|
||||
--seitenleiste-breite: 248px;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* inline: Tailwind-Klassen verweisen auf die Variablen statt die Werte zu kopieren. */
|
||||
@theme inline {
|
||||
--color-bg: var(--bg);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-surface: var(--surface);
|
||||
--color-raised: var(--raised);
|
||||
--color-border: var(--border);
|
||||
--color-border-strong: var(--border-strong);
|
||||
--color-text: var(--text);
|
||||
--color-muted: var(--text-muted);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-soft: var(--accent-soft);
|
||||
--color-ok: var(--ok);
|
||||
--color-danger: var(--danger);
|
||||
--color-danger-soft: var(--danger-soft);
|
||||
--color-danger-border: var(--danger-border);
|
||||
--color-info: var(--info);
|
||||
|
||||
--font-display: var(--schrift-display);
|
||||
--font-sans: var(--schrift-text);
|
||||
--font-mono: var(--schrift-mono);
|
||||
|
||||
--radius-karte: 16px;
|
||||
--radius-box: 12px;
|
||||
--radius-feld: 10px;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--schrift-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
background: var(--bg);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--schrift-display);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Fokus immer sichtbar und in Messing – auch auf Elementen ohne eigenen Fokusstil. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="7" fill="none" stroke="#E8ECEF" stroke-width="3"/>
|
||||
<circle cx="16" cy="13.5" r="4.6" fill="#F0B43C"/>
|
||||
<path d="M13.8 15.5 L12 23.5 H20 L18.2 15.5 Z" fill="#F0B43C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 285 B |
+18
-1
@@ -1,10 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Schriften selbst gehostet (#002): keine CDNs, nur die Gewichte, die das Design nutzt,
|
||||
// und nur der Latin-Ausschnitt (enthält die deutschen Umlaute und ß).
|
||||
import "@fontsource/space-grotesk/latin-500.css";
|
||||
import "@fontsource/space-grotesk/latin-600.css";
|
||||
import "@fontsource/space-grotesk/latin-700.css";
|
||||
import "@fontsource/ibm-plex-sans/latin-400.css";
|
||||
import "@fontsource/ibm-plex-sans/latin-500.css";
|
||||
import "@fontsource/ibm-plex-sans/latin-600.css";
|
||||
import "@fontsource/jetbrains-mono/latin-400.css";
|
||||
import "@fontsource/jetbrains-mono/latin-500.css";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { default: "SSHKeyVault", template: "%s · SSHKeyVault" },
|
||||
description: "Zentrale Verwaltung von SSH-Schlüsseln",
|
||||
applicationName: "SSHKeyVault",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#0D1013",
|
||||
colorScheme: "dark",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
/** Web-App-Manifest; die Icons erzeugt scripts/icons.mjs (#002). */
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "SSHKeyVault",
|
||||
short_name: "sshkv",
|
||||
description: "Zentrale Verwaltung von SSH-Schlüsseln",
|
||||
lang: "de",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#0D1013",
|
||||
theme_color: "#0D1013",
|
||||
icons: [
|
||||
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
|
||||
{ src: "/brand/favicon.svg", sizes: "any", type: "image/svg+xml" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function Startseite() {
|
||||
return <main>SSHKeyVault</main>;
|
||||
}
|
||||
Reference in New Issue
Block a user