Web-Anwendung: Design-Tokens, Texte, Gesundheitsendpunkt
Design-Tokens aus docs/10-design-system.md als CSS-Variablen: Grundpalette, die sechs Klassifizierungsfarben, Eckenradius durchgehend 2 px, keine Schatten ausser dem Fokusring. Der Schwärzungsbalken mit dem einmaligen Durchwischen respektiert prefers-reduced-motion. Vier Tests halten die Tokens gegen das Dokument. IBM Plex selbst gehostet als woff2, latin und latin-ext getrennt über unicode-range, font-display: swap, immutable-Cache. Alle Oberflächentexte in lib/i18n/de.ts. Ein Test durchsucht app/ und components/ nach Umlauten im JSX und schlägt fehl, wenn dort deutscher Text steht. Er hat beim ersten Lauf gleich einen Verstoss gefunden. /api/health prüft Datenbank, Discord-Gateway und ClamAV. Die Gesamtbewertung fällt nur auf "down", wenn die Datenbank weg ist: die anderen Dienste haben eigene Healthchecks im Stack, und ein ausgefallener Virenscanner soll nicht den Web-Container als tot markieren. Strikte CSP mit Nonce, kein unsafe-inline für Skripte. Die erste Route liegt bei 100 kB JavaScript nach Kompression, Budget sind 120 kB.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { collectHealth } from '@/lib/health/check'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
/**
|
||||
* Wird vom Healthcheck des Dienstes `web` benutzt, siehe docs/11-betrieb.md.
|
||||
* 200 solange die Datenbank erreichbar ist, 503 sonst.
|
||||
*/
|
||||
export async function GET(): Promise<Response> {
|
||||
const report = await collectHealth()
|
||||
return Response.json(report, {
|
||||
status: report.status === 'down' ? 503 : 200,
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { de } from '@/lib/i18n/de'
|
||||
|
||||
export default function Error({ reset }: { error: Error; reset: () => void }) {
|
||||
return (
|
||||
<main id="inhalt" className="mx-auto max-w-(--container-measure) px-6 py-12">
|
||||
<h1 className="font-condensed">{de.errors.unexpected.title}</h1>
|
||||
<p className="mt-2 text-text-dim">{de.errors.unexpected.body}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="mt-4 border border-line bg-raised px-3 py-1.5 font-condensed text-xs"
|
||||
>
|
||||
{de.common.retry}
|
||||
</button>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/* IBM Plex, selbst gehostet, als woff2 untergesetzt (latin und latin-ext).
|
||||
Die Wahl ist in docs/10-design-system.md begruendet: Plex wurde als Hausschrift
|
||||
einer Institution entworfen, hat einen schmalen Schnitt fuer Tabellenkoepfe und
|
||||
eine passende Mono fuer Kennzeichen.
|
||||
font-display: swap nach docs/09-performance-und-ux.md. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/ibm-plex-sans-latin-400-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
|
||||
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/ibm-plex-sans-latin-ext-400-normal.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308,
|
||||
U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/ibm-plex-sans-latin-500-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
|
||||
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/ibm-plex-sans-latin-ext-500-normal.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308,
|
||||
U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans Condensed';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/ibm-plex-sans-condensed-latin-500-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
|
||||
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans Condensed';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/ibm-plex-sans-condensed-latin-ext-500-normal.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308,
|
||||
U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/ibm-plex-mono-latin-400-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
|
||||
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/ibm-plex-mono-latin-ext-400-normal.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308,
|
||||
U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
|
||||
U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
@import 'tailwindcss';
|
||||
@import './fonts.css';
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Design-Tokens. Fachliche Grundlage: docs/10-design-system.md.
|
||||
Bezugspunkt ist eine Registratur im Nachtdienst: lackierter Stahl,
|
||||
Aktenpapier, Stempelfarbe. Die einzige Farbe kommt von der Klassifizierung.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
@theme {
|
||||
/* Grundpalette, dunkel ist der Auslieferungszustand */
|
||||
--color-ink-900: #15181a; /* Grundflaeche, kuehles Anthrazit, leicht gruenstichig */
|
||||
--color-ink-800: #1d2124; /* Panel */
|
||||
--color-ink-700: #262b2f; /* erhoehte Flaeche, Eingabefelder */
|
||||
--color-ink-500: #3a4147; /* Linien und Rahmen */
|
||||
--color-paper: #e4e7e4; /* Primaertext, papierfarbenes Off-White */
|
||||
--color-paper-dim: #939c99; /* Sekundaertext */
|
||||
--color-steel: #7a97a6; /* interaktiv: Links, Fokus, aktive Zustaende */
|
||||
|
||||
/* Klassifizierungsfarben. Das einzige Buntsystem, nirgends sonst benutzt. */
|
||||
--color-stamp-0: #8a918d; /* Offen, Grau */
|
||||
--color-stamp-1: #6d8a9c; /* Intern, Stahlblau */
|
||||
--color-stamp-2: #4e8f72; /* Vertraulich, Gruenspan */
|
||||
--color-stamp-3: #b08637; /* Geheim, Messing */
|
||||
--color-stamp-4: #b4543c; /* Streng geheim, Oxidrot */
|
||||
--color-stamp-5: #7e5e96; /* Leitungssache, Tiefviolett */
|
||||
|
||||
/* Semantische Ebene. Das helle Thema tauscht nur diese Werte. */
|
||||
--color-surface: var(--color-ink-900);
|
||||
--color-panel: var(--color-ink-800);
|
||||
--color-raised: var(--color-ink-700);
|
||||
--color-line: var(--color-ink-500);
|
||||
--color-text: var(--color-paper);
|
||||
--color-text-dim: var(--color-paper-dim);
|
||||
--color-accent: var(--color-steel);
|
||||
|
||||
/* Schrift. IBM Plex, selbst gehostet, drei Rollen aus einer Familie. */
|
||||
--font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-condensed: 'IBM Plex Sans Condensed', 'IBM Plex Sans', ui-sans-serif, sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
|
||||
/* Skala 12 / 13 / 15 / 18 / 24 px */
|
||||
--text-2xs: 0.75rem; /* 12 */
|
||||
--text-xs: 0.8125rem; /* 13 */
|
||||
--text-sm: 0.9375rem; /* 15 */
|
||||
--text-base: 0.9375rem; /* 15, Fliesstext */
|
||||
--text-lg: 1.125rem; /* 18 */
|
||||
--text-xl: 1.5rem; /* 24 */
|
||||
|
||||
--text-2xs--line-height: 1.4;
|
||||
--text-xs--line-height: 1.45;
|
||||
--text-sm--line-height: 1.6;
|
||||
--text-base--line-height: 1.6;
|
||||
--text-lg--line-height: 1.45;
|
||||
--text-xl--line-height: 1.3;
|
||||
|
||||
/* Struktur: Eckenradius 2 px, ueberall gleich. Akten sind nicht abgerundet. */
|
||||
--radius-none: 0px;
|
||||
--radius-sm: 2px;
|
||||
--radius-DEFAULT: 2px;
|
||||
--radius-md: 2px;
|
||||
--radius-lg: 2px;
|
||||
--radius-xl: 2px;
|
||||
|
||||
/* Keine Schatten ausser dem Fokusring. */
|
||||
--shadow-sm: none;
|
||||
--shadow-DEFAULT: none;
|
||||
--shadow-md: none;
|
||||
--shadow-lg: none;
|
||||
|
||||
/* Zeilenlaenge hoechstens 72 Zeichen */
|
||||
--container-measure: 72ch;
|
||||
|
||||
/* Die 3 px breite Klassifizierungskante links am Objekt */
|
||||
--spacing-edge: 3px;
|
||||
}
|
||||
|
||||
/* Helles Thema. Verpflichtend mitzuliefern, ausgebaut in Phase 9. */
|
||||
[data-theme='light'] {
|
||||
--color-surface: #eceeea;
|
||||
--color-panel: #e2e5e1;
|
||||
--color-raised: #f4f5f2;
|
||||
--color-line: #b9beb8;
|
||||
--color-text: #1b1f21;
|
||||
--color-text-dim: #545c58;
|
||||
--color-accent: #3d5f70;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
border-color: var(--color-line);
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Spaltenkoepfe, Stempel und Reiter tragen den schmalen Schnitt.
|
||||
Kein Versalsatz: der Stempelcharakter kommt aus Schnitt, Rahmen und Farbe. */
|
||||
th,
|
||||
legend {
|
||||
font-family: var(--font-condensed);
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* Aktenzeichen, Zeitstempel, Hashes und Dienstnummern in Mono. */
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
time {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-weight: 500;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Sichtbarer Fokusring in --steel, der einzige Schatten im System. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: color-mix(in srgb, var(--color-accent) 40%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Der eine mutige Moment: die Schwaerzung. Ein massiver Balken mit feiner
|
||||
Rahmenlinie, der beim ersten Erscheinen einmal von links nach rechts
|
||||
durchwischt. Sonst bewegt sich in der Oberflaeche nichts von selbst. */
|
||||
.redaction {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
vertical-align: baseline;
|
||||
background-color: var(--color-ink-900);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 2px;
|
||||
color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.redaction::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--color-ink-700);
|
||||
transform-origin: left center;
|
||||
animation: redaction-wipe 150ms ease-out 1 both;
|
||||
}
|
||||
|
||||
@keyframes redaction-wipe {
|
||||
from {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
to {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Zeitachse des Verlaufs: durchgehende senkrechte Linie. Der Chat hat keine.
|
||||
Der Unterschied muss ohne Beschriftung erkennbar sein. */
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.4375rem;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: var(--color-line);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.redaction::after {
|
||||
animation: none;
|
||||
transform: scaleX(0);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { default: de.app.name, template: `%s · ${de.app.shortName}` },
|
||||
description: de.app.description,
|
||||
robots: { index: false, follow: false },
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#15181A',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<head>
|
||||
{/* Nur die beiden Schnitte vorladen, die auf jeder Seite vorkommen. */}
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/ibm-plex-sans-latin-400-normal.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/ibm-plex-mono-latin-400-normal.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<a
|
||||
href="#inhalt"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:border focus:border-line focus:bg-panel focus:px-3 focus:py-2"
|
||||
>
|
||||
{de.nav.skipToContent}
|
||||
</a>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { SkeletonLine, Skeleton } from '@/components/Skeleton'
|
||||
|
||||
/**
|
||||
* Jede Route hat ein Skelett, das die spaetere Struktur nachbildet.
|
||||
* Nie eine leere Seite mit Spinner, siehe docs/09-performance-und-ux.md.
|
||||
*/
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="mx-auto max-w-(--container-measure) px-6 py-12">
|
||||
<Skeleton className="h-[1.5rem]" width="18rem" />
|
||||
<div className="mt-3">
|
||||
<SkeletonLine />
|
||||
<SkeletonLine width="70%" />
|
||||
</div>
|
||||
<hr className="my-8 border-line" />
|
||||
<Skeleton className="h-[1.125rem]" width="10rem" />
|
||||
<div className="mt-4">
|
||||
<SkeletonLine />
|
||||
<SkeletonLine />
|
||||
<SkeletonLine />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { de } from '@/lib/i18n/de'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main id="inhalt" className="mx-auto max-w-(--container-measure) px-6 py-12">
|
||||
<h1 className="font-condensed">{de.errors.notFound.title}</h1>
|
||||
<p className="mt-2 text-text-dim">{de.errors.notFound.body}</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Suspense } from 'react'
|
||||
import { collectHealth, type ComponentState } from '@/lib/health/check'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { SkeletonLine } from '@/components/Skeleton'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const STATE_LABEL: Record<ComponentState, string> = {
|
||||
up: de.health.ok,
|
||||
degraded: de.health.degraded,
|
||||
down: de.health.down,
|
||||
}
|
||||
|
||||
async function SystemState() {
|
||||
const report = await collectHealth()
|
||||
return (
|
||||
<table className="w-full max-w-(--container-measure) border-collapse text-sm">
|
||||
<caption className="pb-2 text-left font-condensed text-xs text-text-dim">
|
||||
{de.health.checkedAt(new Date(report.checkedAt).toLocaleString('de-DE'))}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-line text-left text-xs text-text-dim">
|
||||
<th scope="col" className="py-1.5 pr-4 font-normal">
|
||||
{de.health.service}
|
||||
</th>
|
||||
<th scope="col" className="py-1.5 font-normal">
|
||||
{de.health.state}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(report.components).map(([key, value]) => (
|
||||
<tr key={key} className="border-b border-line/60">
|
||||
<td className="py-2 pr-4">{de.health.components[key] ?? key}</td>
|
||||
<td className="py-2">
|
||||
{/* Zustand als Text, nicht nur als Farbe. */}
|
||||
<span className="font-condensed">{STATE_LABEL[value.status]}</span>
|
||||
{value.detail ? (
|
||||
<span className="ml-2 font-mono text-2xs text-text-dim">{value.detail}</span>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main id="inhalt" className="mx-auto max-w-(--container-measure) px-6 py-12">
|
||||
<h1 className="font-condensed">{de.app.name}</h1>
|
||||
<p className="mt-2 max-w-(--container-measure) text-text-dim">{de.app.description}</p>
|
||||
|
||||
<hr className="my-8 border-line" />
|
||||
|
||||
<h2 className="mb-4 font-condensed">{de.health.heading}</h2>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="max-w-(--container-measure)">
|
||||
<SkeletonLine width="14rem" />
|
||||
<SkeletonLine />
|
||||
<SkeletonLine />
|
||||
<SkeletonLine />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SystemState />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user