fix(ux): editor click bug (@js→index), self-hosted fonts, split-brand auth, R14
- Fix Alpine "Invalid or unexpected token" on file/service/key actions: @js()
does NOT compile inside a Blade component (x-btn) attribute, so a literal
@js(...) reached the DOM and Alpine choked on it. Switched all 10 wire:click
arguments to loop-index lookups (files: open/go/download/edit/confirmDelete;
services: confirm; keys: confirmKeyRemoval) — the component resolves the value
server-side by index. {{ $loop->index }} compiles in component attributes; @js
does not.
- Self-host fonts (R14): Chakra Petch / Space Grotesk / JetBrains Mono as local
.woff2 in resources/fonts/, declared via @font-face in app.css, Vite-bundled
with relative urls so they resolve in dev AND the prod build. No Google Fonts
/ CDN link or @import. New rule R14 added to rules.md + CLAUDE.md.
- Auth redesign (split-brand, per reference): new auth layout with a brand panel
(faux terminal + glow mesh, hidden below lg) + redesigned login,
two-factor-challenge, two-factor-setup, password-change forms.
- x-btn: add size="lg" (h-11, >=44px touch target) for the full-width auth CTA;
one shared button component, no bespoke styles.
- Remove unused Laravel welcome.blade.php (was full of raw hex — R3 cleanup).
Verified (R12): /login 200 / 0 console errors, fonts load (Chakra+Grotesk+JB),
brand panel visible @1440; file-editor REAL click -> modal opens, textarea
renders, 0 Alpine errors; editor load/save roundtrip (/etc/hostname='debian');
all 7 routes 200 / 0 console errors. Full rules audit R1-R14 clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
parent
f418ab35ab
commit
aa7a7b1db8
|
|
@ -49,7 +49,7 @@ dashboard**, connecting **agentless over SSH**. The operator sees and steers the
|
||||||
| SSH | **phpseclib** (exec + SFTP) |
|
| SSH | **phpseclib** (exec + SFTP) |
|
||||||
| Charts | JS lib (ApexCharts / Chart.js / uPlot) as an **Alpine island** in Blade |
|
| Charts | JS lib (ApexCharts / Chart.js / uPlot) as an **Alpine island** in Blade |
|
||||||
| Icons | **Lucide**, inline SVG via a Blade `x-icon` component |
|
| Icons | **Lucide**, inline SVG via a Blade `x-icon` component |
|
||||||
| Fonts | **Chakra Petch** (display) · **Space Grotesk** (sans) · **JetBrains Mono** (numbers/paths) — self-host `.woff2`, no CDN in prod |
|
| Fonts | **Chakra Petch** (display) · **Space Grotesk** (sans) · **JetBrains Mono** (numbers/paths) — **self-hosted only** (`public/fonts/*.woff2` + `@font-face` in `app.css`), **never a CDN/Google Fonts link or `@import`** (R14) |
|
||||||
|
|
||||||
> **Version note (2026-06-11):** handoff §3 specifies Laravel 12; **Laravel 13** is used (it is the current release; user decision). Livewire 3 / Tailwind 4 / wire-elements/modal / Reverb are unaffected; PHP pinned to **8.3**.
|
> **Version note (2026-06-11):** handoff §3 specifies Laravel 12; **Laravel 13** is used (it is the current release; user decision). Livewire 3 / Tailwind 4 / wire-elements/modal / Reverb are unaffected; PHP pinned to **8.3**.
|
||||||
|
|
||||||
|
|
@ -156,6 +156,9 @@ number/IP/path**. All tokens live in `resources/css/app.css` (R3). Token groups:
|
||||||
PK (`getRouteKeyName(): 'uuid'`). (R11)
|
PK (`getRouteKeyName(): 'uuid'`). (R11)
|
||||||
- **Route paths + names are English**, always — `/settings` not `/einstellungen`, `/files` not
|
- **Route paths + names are English**, always — `/settings` not `/einstellungen`, `/files` not
|
||||||
`/dateien`. German lives in the visible nav label, never in the `href`/route name. (R13)
|
`/dateien`. German lives in the visible nav label, never in the `href`/route name. (R13)
|
||||||
|
- **Fonts self-hosted only** — `.woff2` in `resources/fonts/` (Vite-bundled, relative
|
||||||
|
`url('../fonts/…')`) + `@font-face` in `app.css`; never a Google Fonts / CDN `<link>` or
|
||||||
|
`@import`. (R14)
|
||||||
- **Responsive:** every screen at **375 / 768 / 1280+**; sidebar → drawer on small; KPI grid
|
- **Responsive:** every screen at **375 / 768 / 1280+**; sidebar → drawer on small; KPI grid
|
||||||
4→2→1; tables scroll/stack; touch targets ≥ 44px. (R7)
|
4→2→1; tables scroll/stack; touch targets ≥ 44px. (R7)
|
||||||
- **Docs language:** these meta-docs are in English; **UI strings are German**.
|
- **Docs language:** these meta-docs are in English; **UI strings are German**.
|
||||||
|
|
|
||||||
|
|
@ -53,14 +53,20 @@ class Index extends Component
|
||||||
$this->ready = true;
|
$this->ready = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function open(string $name): void
|
public function open(int $index): void
|
||||||
{
|
{
|
||||||
|
$name = $this->entries[$index]['name'] ?? null;
|
||||||
|
if ($name === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->path = rtrim($this->path, '/').'/'.$name;
|
$this->path = rtrim($this->path, '/').'/'.$name;
|
||||||
$this->load();
|
$this->load();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function go(string $path): void
|
public function go(int $index): void
|
||||||
{
|
{
|
||||||
|
$path = $this->crumbs[$index]['path'] ?? '/';
|
||||||
$this->path = $path !== '' ? $path : '/';
|
$this->path = $path !== '' ? $path : '/';
|
||||||
$this->load();
|
$this->load();
|
||||||
}
|
}
|
||||||
|
|
@ -92,10 +98,11 @@ class Index extends Component
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stream a remote file to the browser as a download (SFTP get). */
|
/** Stream a remote file to the browser as a download (SFTP get). */
|
||||||
public function download(string $name, FleetService $fleet)
|
public function download(int $index, FleetService $fleet)
|
||||||
{
|
{
|
||||||
|
$name = $this->entries[$index]['name'] ?? null;
|
||||||
$active = $this->activeServer();
|
$active = $this->activeServer();
|
||||||
if (! $active || ! $active->credential_exists) {
|
if ($name === null || ! $active || ! $active->credential_exists) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,10 +118,11 @@ class Index extends Component
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the view/edit modal for a file. */
|
/** Open the view/edit modal for a file. */
|
||||||
public function edit(string $name): void
|
public function edit(int $index): void
|
||||||
{
|
{
|
||||||
|
$name = $this->entries[$index]['name'] ?? null;
|
||||||
$active = $this->activeServer();
|
$active = $this->activeServer();
|
||||||
if (! $active) {
|
if ($name === null || ! $active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,8 +164,13 @@ class Index extends Component
|
||||||
* Delete a file (R5): opens the confirm modal, which writes the AuditEvent
|
* Delete a file (R5): opens the confirm modal, which writes the AuditEvent
|
||||||
* and re-dispatches `fileConfirmed`. SFTP unlink is wired in behind this later.
|
* and re-dispatches `fileConfirmed`. SFTP unlink is wired in behind this later.
|
||||||
*/
|
*/
|
||||||
public function confirmDelete(string $name): void
|
public function confirmDelete(int $index): void
|
||||||
{
|
{
|
||||||
|
$name = $this->entries[$index]['name'] ?? null;
|
||||||
|
if ($name === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->dispatch('openModal',
|
$this->dispatch('openModal',
|
||||||
component: 'modals.confirm-action',
|
component: 'modals.confirm-action',
|
||||||
arguments: [
|
arguments: [
|
||||||
|
|
|
||||||
|
|
@ -95,8 +95,16 @@ class Show extends Component
|
||||||
* Revoke an SSH key (R5): opens the confirm modal, which writes the
|
* Revoke an SSH key (R5): opens the confirm modal, which writes the
|
||||||
* AuditEvent and re-dispatches `keyRemoved`. SSH layer removal lands later.
|
* AuditEvent and re-dispatches `keyRemoved`. SSH layer removal lands later.
|
||||||
*/
|
*/
|
||||||
public function confirmKeyRemoval(string $fingerprint, string $comment): void
|
public function confirmKeyRemoval(int $index): void
|
||||||
{
|
{
|
||||||
|
$key = $this->sshKeys[$index] ?? null;
|
||||||
|
if ($key === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fingerprint = $key['fingerprint'];
|
||||||
|
$comment = $key['comment'];
|
||||||
|
|
||||||
$this->dispatch('openModal',
|
$this->dispatch('openModal',
|
||||||
component: 'modals.confirm-action',
|
component: 'modals.confirm-action',
|
||||||
arguments: [
|
arguments: [
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,13 @@ class Index extends Component
|
||||||
* modal writes the AuditEvent and re-dispatches `serviceConfirmed`, which
|
* modal writes the AuditEvent and re-dispatches `serviceConfirmed`, which
|
||||||
* applyService() handles. SSH exec (systemctl) is wired in behind this later.
|
* applyService() handles. SSH exec (systemctl) is wired in behind this later.
|
||||||
*/
|
*/
|
||||||
public function confirm(string $op, string $name): void
|
public function confirm(string $op, int $index): void
|
||||||
{
|
{
|
||||||
|
$name = $this->filteredServices[$index]['name'] ?? null;
|
||||||
|
if ($name === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
[$heading, $phrase, $label, $action, $icon] = match ($op) {
|
[$heading, $phrase, $label, $action, $icon] = match ($op) {
|
||||||
'start' => ['Dienst starten', 'wird gestartet', 'Starten', 'service.start', 'power'],
|
'start' => ['Dienst starten', 'wird gestartet', 'Starten', 'service.start', 'power'],
|
||||||
'stop' => ['Dienst stoppen', 'wird gestoppt', 'Stoppen', 'service.stop', 'power'],
|
'stop' => ['Dienst stoppen', 'wird gestoppt', 'Stoppen', 'service.stop', 'power'],
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,50 @@
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
@source '../../storage/framework/views/*.php';
|
@source '../../storage/framework/views/*.php';
|
||||||
|
|
||||||
|
/* ── Self-hosted fonts (R14 — local only, never a CDN/@import from Google) ─ */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Chakra Petch';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/chakra-petch-400.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Chakra Petch';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/chakra-petch-500.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Chakra Petch';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/chakra-petch-600.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Chakra Petch';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/chakra-petch-700.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Grotesk';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/space-grotesk.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/jetbrains-mono.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Clusev design tokens — "Tactical Terminal" (handoff §6) ───────────── */
|
/* ── Clusev design tokens — "Tactical Terminal" (handoff §6) ───────────── */
|
||||||
@theme {
|
@theme {
|
||||||
/* surfaces */
|
/* surfaces */
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,4 +1,4 @@
|
||||||
@props(['variant' => 'secondary', 'icon' => false, 'href' => null])
|
@props(['variant' => 'secondary', 'icon' => false, 'href' => null, 'size' => 'sm'])
|
||||||
@php
|
@php
|
||||||
// One button style for the whole app — compact, consistent (R10).
|
// One button style for the whole app — compact, consistent (R10).
|
||||||
$variants = [
|
$variants = [
|
||||||
|
|
@ -9,10 +9,14 @@
|
||||||
'ghost' => 'text-ink-2 hover:bg-raised hover:text-ink',
|
'ghost' => 'text-ink-2 hover:bg-raised hover:text-ink',
|
||||||
'ghost-danger' => 'text-ink-3 hover:bg-offline/10 hover:text-offline',
|
'ghost-danger' => 'text-ink-3 hover:bg-offline/10 hover:text-offline',
|
||||||
];
|
];
|
||||||
$shape = $icon ? 'h-8 w-8 justify-center' : 'h-8 px-3';
|
// sm = compact toolbar/row buttons; lg = full-height primary CTA (≥44px touch target, R7).
|
||||||
$classes = 'inline-flex items-center gap-1.5 rounded-md text-xs font-medium transition-colors '
|
$sizes = [
|
||||||
|
'sm' => ($icon ? 'h-8 w-8' : 'h-8 px-3').' text-xs',
|
||||||
|
'lg' => ($icon ? 'h-11 w-11' : 'h-11 px-4').' text-sm',
|
||||||
|
];
|
||||||
|
$classes = 'inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors '
|
||||||
.'disabled:opacity-50 disabled:pointer-events-none '
|
.'disabled:opacity-50 disabled:pointer-events-none '
|
||||||
.$shape.' '.($variants[$variant] ?? $variants['secondary']);
|
.($sizes[$size] ?? $sizes['sm']).' '.($variants[$variant] ?? $variants['secondary']);
|
||||||
@endphp
|
@endphp
|
||||||
@if ($href)
|
@if ($href)
|
||||||
<a href="{{ $href }}" {{ $attributes->merge(['class' => $classes]) }}>{{ $slot }}</a>
|
<a href="{{ $href }}" {{ $attributes->merge(['class' => $classes]) }}>{{ $slot }}</a>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
// Lucide SVG paths (24x24, stroke). Add icons here as needed.
|
// Lucide SVG paths (24x24, stroke). Add icons here as needed.
|
||||||
$paths = [
|
$paths = [
|
||||||
'menu' => '<path d="M4 12h16"/><path d="M4 6h16"/><path d="M4 18h16"/>',
|
'menu' => '<path d="M4 12h16"/><path d="M4 6h16"/><path d="M4 18h16"/>',
|
||||||
|
'chevron-left' => '<path d="m15 18-6-6 6-6"/>',
|
||||||
'x' => '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
'x' => '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||||
'dashboard' => '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
|
'dashboard' => '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
|
||||||
'server' => '<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/>',
|
'server' => '<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/>',
|
||||||
|
|
|
||||||
|
|
@ -8,20 +8,73 @@
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
@livewireStyles
|
@livewireStyles
|
||||||
</head>
|
</head>
|
||||||
<body class="grid min-h-screen place-items-center bg-void px-4 py-8 font-sans text-ink antialiased selection:bg-accent/25">
|
<body class="min-h-screen bg-void font-sans text-ink antialiased selection:bg-accent/25">
|
||||||
<div class="relative z-10 w-full max-w-sm">
|
<div class="grid min-h-screen lg:grid-cols-[1.05fr_0.95fr]">
|
||||||
<div class="mb-6 flex items-center justify-center gap-2.5">
|
{{-- Brand panel — hidden below lg --}}
|
||||||
<span class="grid h-9 w-9 place-items-center rounded-md border border-accent/25 bg-accent/10 text-accent shadow-[0_0_18px_-2px_var(--color-accent)]">
|
<aside class="relative hidden overflow-hidden border-r border-line bg-base p-10 lg:flex lg:flex-col lg:justify-between xl:p-12">
|
||||||
<x-icon name="server" class="h-5 w-5" />
|
{{-- atmospheric glow mesh --}}
|
||||||
</span>
|
<div class="pointer-events-none absolute -left-28 -top-28 h-96 w-96 rounded-full bg-accent/10 blur-3xl" aria-hidden="true"></div>
|
||||||
<span class="font-display text-2xl font-semibold tracking-wide text-ink">Clus<span class="text-accent">e</span>v</span>
|
<div class="pointer-events-none absolute -bottom-32 -right-24 h-96 w-96 rounded-full bg-cyan/[0.07] blur-3xl" aria-hidden="true"></div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-panel">
|
{{-- top: wordmark --}}
|
||||||
{{ $slot }}
|
<div class="relative z-10 flex items-center gap-2.5">
|
||||||
</div>
|
<span class="grid h-9 w-9 place-items-center rounded-md border border-accent/25 bg-accent/10 text-accent shadow-[0_0_18px_-2px_var(--color-accent)]">
|
||||||
|
<x-icon name="server" class="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<span class="font-display text-xl font-semibold tracking-wide text-ink">Clus<span class="text-accent">e</span>v</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="mt-4 text-center font-mono text-[11px] text-ink-4">Fleet Control · agentless über SSH</p>
|
{{-- middle: headline + faux terminal --}}
|
||||||
|
<div class="relative z-10 flex max-w-md flex-col gap-7">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h1 class="text-balance font-display text-4xl font-bold leading-[1.12] tracking-tight text-ink">
|
||||||
|
Eine Konsole für deine <span class="text-accent">gesamte Flotte</span>.
|
||||||
|
</h1>
|
||||||
|
<p class="max-w-sm text-sm leading-relaxed text-ink-2">
|
||||||
|
Agentenlos über SSH. Metriken, Dienste, Dateien und ein lückenloses Audit-Log — sicher aus einem Panel gesteuert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-lg border border-line bg-inset shadow-panel">
|
||||||
|
<div class="flex items-center gap-2 border-b border-line bg-raised px-3 py-2.5">
|
||||||
|
<span class="flex gap-1.5">
|
||||||
|
<span class="h-2 w-2 rounded-full bg-offline/50"></span>
|
||||||
|
<span class="h-2 w-2 rounded-full bg-warning/50"></span>
|
||||||
|
<span class="h-2 w-2 rounded-full bg-online/50"></span>
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-[10px] text-ink-4">ssh · control-plane</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5 p-4 font-mono text-xs leading-relaxed">
|
||||||
|
<p class="text-ink-2"><span class="text-ink-4">$</span> clusev connect <span class="text-accent-text">10.10.90.0/24</span></p>
|
||||||
|
<p class="text-online">24 Hosts erreichbar · Host-Keys gepinnt</p>
|
||||||
|
<p class="text-ink-2"><span class="text-ink-4">$</span> fleet status <span class="text-accent-text">--live</span></p>
|
||||||
|
<p class="text-ink-3">cpu 31% · mem 48% · load 0.86 · 0 Alarme</p>
|
||||||
|
<p class="flex items-center text-ink-2"><span class="text-ink-4">$</span><span class="ml-2 inline-block h-3.5 w-[7px] animate-pulse bg-accent"></span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- bottom: copyright + status --}}
|
||||||
|
<div class="relative z-10 flex items-center justify-between gap-4 font-mono text-[11px] text-ink-4">
|
||||||
|
<span>© {{ date('Y') }} Clusev · Fleet Control</span>
|
||||||
|
<span class="inline-flex items-center gap-1.5"><span class="h-1.5 w-1.5 rounded-full bg-online"></span> betriebsbereit</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{{-- Form panel --}}
|
||||||
|
<main class="relative flex items-center justify-center px-5 py-10 sm:px-8">
|
||||||
|
<div class="w-full max-w-sm">
|
||||||
|
{{-- wordmark for small screens (brand panel hidden) --}}
|
||||||
|
<div class="mb-8 flex items-center gap-2.5 lg:hidden">
|
||||||
|
<span class="grid h-9 w-9 place-items-center rounded-md border border-accent/25 bg-accent/10 text-accent shadow-[0_0_18px_-2px_var(--color-accent)]">
|
||||||
|
<x-icon name="server" class="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<span class="font-display text-xl font-semibold tracking-wide text-ink">Clus<span class="text-accent">e</span>v</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ $slot }}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@livewireScripts
|
@livewireScripts
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,51 @@
|
||||||
<div>
|
@php
|
||||||
<h1 class="font-display text-lg font-semibold text-ink">Anmelden</h1>
|
$field = 'h-11 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
|
||||||
<p class="mt-1 text-sm text-ink-3">Zugang zum Clusev-Control-Panel.</p>
|
$label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
|
||||||
|
$err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline';
|
||||||
|
@endphp
|
||||||
|
|
||||||
<form wire:submit="authenticate" class="mt-5 space-y-4">
|
<div class="space-y-7">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">Control Plane</p>
|
||||||
|
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">Anmelden</h1>
|
||||||
|
<p class="font-mono text-xs text-ink-3">Zugang zum Clusev-Panel.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form wire:submit="authenticate" class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="email" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">E-Mail</label>
|
<label for="email" class="{{ $label }}">E-Mail</label>
|
||||||
<input wire:model="email" id="email" type="email" autocomplete="username" autofocus
|
<input wire:model="email" id="email" type="email" autocomplete="username" autofocus
|
||||||
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink placeholder:text-ink-4 focus-visible:border-accent/50"
|
class="{{ $field }}" placeholder="admin@clusev.local" />
|
||||||
placeholder="admin@clusev.local" />
|
@error('email') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
|
||||||
@error('email') <p class="mt-1.5 text-xs text-offline">{{ $message }}</p> @enderror
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="password" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Passwort</label>
|
<label for="password" class="{{ $label }}">Passwort</label>
|
||||||
<input wire:model="password" id="password" type="password" autocomplete="current-password"
|
<input wire:model="password" id="password" type="password" autocomplete="current-password"
|
||||||
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink placeholder:text-ink-4 focus-visible:border-accent/50"
|
class="{{ $field }}" placeholder="••••••••" />
|
||||||
placeholder="••••••••" />
|
@error('password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
|
||||||
@error('password') <p class="mt-1.5 text-xs text-offline">{{ $message }}</p> @enderror
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="flex items-center gap-2 text-sm text-ink-2">
|
<label class="flex items-center gap-2 font-mono text-xs text-ink-2">
|
||||||
<input wire:model="remember" type="checkbox" class="h-4 w-4 rounded border-line bg-inset accent-accent" />
|
<input wire:model="remember" type="checkbox" class="h-4 w-4 rounded border-line bg-inset accent-accent" />
|
||||||
Angemeldet bleiben
|
Angemeldet bleiben
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit" wire:loading.attr="disabled" wire:target="authenticate"
|
<x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="authenticate">
|
||||||
class="flex min-h-11 w-full items-center justify-center rounded-md bg-accent px-4 py-2 font-display text-sm font-semibold uppercase tracking-wide text-void transition-colors hover:bg-accent-bright disabled:opacity-60">
|
<svg wire:loading wire:target="authenticate" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||||
<span wire:loading.remove wire:target="authenticate">Anmelden</span>
|
<span wire:loading.remove wire:target="authenticate">Anmelden</span>
|
||||||
<span wire:loading wire:target="authenticate">Prüfe…</span>
|
<span wire:loading wire:target="authenticate">Prüfe…</span>
|
||||||
</button>
|
</x-btn>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="h-px flex-1 bg-line"></span>
|
||||||
|
<span class="font-mono text-[10px] uppercase tracking-[0.14em] text-ink-4">Gesichert</span>
|
||||||
|
<span class="h-px flex-1 bg-line"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="flex items-start gap-2 font-mono text-[11px] leading-relaxed text-ink-4">
|
||||||
|
<x-icon name="shield" class="mt-px h-3.5 w-3.5 shrink-0 text-online" />
|
||||||
|
Verbindung über SSH · 2FA erforderlich · jede Aktion landet im Audit-Log.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,38 @@
|
||||||
<div>
|
@php
|
||||||
<h1 class="font-display text-lg font-semibold text-ink">Passwort ändern</h1>
|
$field = 'h-11 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
|
||||||
<p class="mt-1 text-sm text-ink-3">Lege ein eigenes Passwort fest (min. 12 Zeichen, Groß-/Kleinschreibung + Zahl).</p>
|
$label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
|
||||||
|
$err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline';
|
||||||
|
@endphp
|
||||||
|
|
||||||
<form wire:submit="update" class="mt-5 space-y-4">
|
<div class="space-y-7">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">Sicherheit</p>
|
||||||
|
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">Passwort ändern</h1>
|
||||||
|
<p class="font-mono text-xs leading-relaxed text-ink-3">Lege ein eigenes Passwort fest (min. 12 Zeichen, Groß-/Kleinschreibung + Zahl).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form wire:submit="update" class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="current" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Aktuelles Passwort</label>
|
<label for="current" class="{{ $label }}">Aktuelles Passwort</label>
|
||||||
<input wire:model="current" id="current" type="password" autocomplete="current-password"
|
<input wire:model="current" id="current" type="password" autocomplete="current-password" class="{{ $field }}" />
|
||||||
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink focus-visible:border-accent/50" />
|
@error('current') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
|
||||||
@error('current') <p class="mt-1.5 text-xs text-offline">{{ $message }}</p> @enderror
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="password" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Neues Passwort</label>
|
<label for="password" class="{{ $label }}">Neues Passwort</label>
|
||||||
<input wire:model="password" id="password" type="password" autocomplete="new-password"
|
<input wire:model="password" id="password" type="password" autocomplete="new-password" class="{{ $field }}" />
|
||||||
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink focus-visible:border-accent/50" />
|
@error('password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
|
||||||
@error('password') <p class="mt-1.5 text-xs text-offline">{{ $message }}</p> @enderror
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="password_confirmation" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Neues Passwort bestätigen</label>
|
<label for="password_confirmation" class="{{ $label }}">Neues Passwort bestätigen</label>
|
||||||
<input wire:model="password_confirmation" id="password_confirmation" type="password" autocomplete="new-password"
|
<input wire:model="password_confirmation" id="password_confirmation" type="password" autocomplete="new-password" class="{{ $field }}" />
|
||||||
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink focus-visible:border-accent/50" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" wire:loading.attr="disabled" wire:target="update"
|
<x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="update">
|
||||||
class="flex min-h-11 w-full items-center justify-center rounded-md bg-accent px-4 py-2 font-display text-sm font-semibold uppercase tracking-wide text-void transition-colors hover:bg-accent-bright disabled:opacity-60">
|
<svg wire:loading wire:target="update" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||||
Speichern & weiter
|
<span wire:loading.remove wire:target="update">Speichern & weiter</span>
|
||||||
</button>
|
<span wire:loading wire:target="update">Speichere…</span>
|
||||||
|
</x-btn>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,34 @@
|
||||||
<div>
|
@php
|
||||||
<h1 class="font-display text-lg font-semibold text-ink">Zwei-Faktor-Bestätigung</h1>
|
$field = 'h-14 w-full rounded-md border border-line bg-inset text-center font-mono text-2xl font-semibold tracking-[0.5em] text-ink caret-accent placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
|
||||||
<p class="mt-1 text-sm text-ink-3">Gib den 6-stelligen Code aus deiner Authenticator-App ein.</p>
|
$label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
|
||||||
|
$err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline';
|
||||||
|
@endphp
|
||||||
|
|
||||||
<form wire:submit="verify" class="mt-5 space-y-4">
|
<div class="space-y-7">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">Zwei-Faktor</p>
|
||||||
|
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">Bestätigung</h1>
|
||||||
|
<p class="font-mono text-xs text-ink-3">6-stelliger Code aus deiner Authenticator-App.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form wire:submit="verify" class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="code" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Code</label>
|
<label for="code" class="{{ $label }}">Code</label>
|
||||||
<input wire:model="code" id="code" inputmode="numeric" autocomplete="one-time-code" autofocus
|
<input wire:model="code" id="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" autofocus
|
||||||
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-center font-mono text-lg tracking-[0.4em] text-ink placeholder:text-ink-4 focus-visible:border-accent/50"
|
class="{{ $field }}" placeholder="000000" />
|
||||||
placeholder="000000" />
|
@error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
|
||||||
@error('code') <p class="mt-1.5 text-xs text-offline">{{ $message }}</p> @enderror
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" wire:loading.attr="disabled" wire:target="verify"
|
<x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="verify">
|
||||||
class="flex min-h-11 w-full items-center justify-center rounded-md bg-accent px-4 py-2 font-display text-sm font-semibold uppercase tracking-wide text-void transition-colors hover:bg-accent-bright disabled:opacity-60">
|
<svg wire:loading wire:target="verify" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||||
Bestätigen
|
<span wire:loading.remove wire:target="verify">Bestätigen</span>
|
||||||
</button>
|
<span wire:loading wire:target="verify">Prüfe…</span>
|
||||||
|
</x-btn>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<a href="{{ route('login') }}" wire:navigate class="inline-flex items-center gap-1.5 font-mono text-[11px] text-ink-4 transition-colors hover:text-ink-2">
|
||||||
|
<x-icon name="chevron-left" class="h-3.5 w-3.5" /> Zurück zur Anmeldung
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,38 @@
|
||||||
<div>
|
@php
|
||||||
<h1 class="font-display text-lg font-semibold text-ink">Zwei-Faktor einrichten</h1>
|
$field = 'h-14 w-full rounded-md border border-line bg-inset text-center font-mono text-2xl font-semibold tracking-[0.5em] text-ink caret-accent placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
|
||||||
<p class="mt-1 text-sm text-ink-3">Scanne den Code mit einer Authenticator-App (Aegis, Google Authenticator, …) und bestätige mit einem Code.</p>
|
$label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
|
||||||
|
$err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline';
|
||||||
|
@endphp
|
||||||
|
|
||||||
<div class="mt-5 flex justify-center">
|
<div class="space-y-6">
|
||||||
<div class="rounded-md bg-ink p-2">
|
<div class="space-y-1.5">
|
||||||
|
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">Zwei-Faktor</p>
|
||||||
|
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">Einrichten</h1>
|
||||||
|
<p class="font-mono text-xs leading-relaxed text-ink-3">Scanne den Code mit einer Authenticator-App (Aegis, Google Authenticator, …) und bestätige mit einem Code.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<div class="rounded-lg border border-line bg-ink p-3">
|
||||||
<img src="{{ $this->qrCode() }}" alt="2FA-QR-Code" class="h-44 w-44" />
|
<img src="{{ $this->qrCode() }}" alt="2FA-QR-Code" class="h-44 w-44" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="w-full rounded-md border border-line bg-inset px-3 py-2 text-center">
|
||||||
|
<p class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Geheimnis (manuell)</p>
|
||||||
|
<p class="mt-1 break-all font-mono text-xs text-ink-2">{{ $secret }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 text-center">
|
<form wire:submit="confirm" class="space-y-4">
|
||||||
<p class="font-mono text-[11px] uppercase tracking-wider text-ink-4">Geheimnis (manuell)</p>
|
|
||||||
<p class="mt-1 break-all font-mono text-xs text-ink-2">{{ $secret }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form wire:submit="confirm" class="mt-5 space-y-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label for="code" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Bestätigungs-Code</label>
|
<label for="code" class="{{ $label }}">Bestätigungs-Code</label>
|
||||||
<input wire:model="code" id="code" inputmode="numeric" autocomplete="one-time-code" autofocus
|
<input wire:model="code" id="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" autofocus
|
||||||
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-center font-mono text-lg tracking-[0.4em] text-ink placeholder:text-ink-4 focus-visible:border-accent/50"
|
class="{{ $field }}" placeholder="000000" />
|
||||||
placeholder="000000" />
|
@error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
|
||||||
@error('code') <p class="mt-1.5 text-xs text-offline">{{ $message }}</p> @enderror
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" wire:loading.attr="disabled" wire:target="confirm"
|
<x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="confirm">
|
||||||
class="flex min-h-11 w-full items-center justify-center rounded-md bg-accent px-4 py-2 font-display text-sm font-semibold uppercase tracking-wide text-void transition-colors hover:bg-accent-bright disabled:opacity-60">
|
<svg wire:loading wire:target="confirm" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||||
Aktivieren
|
<span wire:loading.remove wire:target="confirm">Aktivieren</span>
|
||||||
</button>
|
<span wire:loading wire:target="confirm">Prüfe…</span>
|
||||||
|
</x-btn>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
@if (! $loop->first)
|
@if (! $loop->first)
|
||||||
<span class="font-mono text-xs text-ink-4">/</span>
|
<span class="font-mono text-xs text-ink-4">/</span>
|
||||||
@endif
|
@endif
|
||||||
<button type="button" wire:click="go(@js($crumb['path']))" @class([
|
<button type="button" wire:click="go({{ $loop->index }})" @class([
|
||||||
'inline-flex min-h-11 min-w-11 items-center justify-center px-2 font-mono text-xs',
|
'inline-flex min-h-11 min-w-11 items-center justify-center px-2 font-mono text-xs',
|
||||||
'text-ink-3' => $loop->last,
|
'text-ink-3' => $loop->last,
|
||||||
'text-accent-text hover:text-accent' => ! $loop->last,
|
'text-accent-text hover:text-accent' => ! $loop->last,
|
||||||
|
|
@ -90,11 +90,11 @@
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@if ($isDir)
|
@if ($isDir)
|
||||||
<button type="button" wire:click="open(@js($e['name']))" class="min-w-0 flex-1 text-left">
|
<button type="button" wire:click="open({{ $loop->index }})" class="min-w-0 flex-1 text-left">
|
||||||
<span class="truncate font-mono text-sm text-ink group-hover:text-accent-text">{{ $e['name'] }}/</span>
|
<span class="truncate font-mono text-sm text-ink group-hover:text-accent-text">{{ $e['name'] }}/</span>
|
||||||
</button>
|
</button>
|
||||||
@else
|
@else
|
||||||
<button type="button" wire:click="edit(@js($e['name']))" class="min-w-0 flex-1 truncate text-left font-mono text-sm text-ink-2 transition-colors hover:text-accent-text">{{ $e['name'] }}</button>
|
<button type="button" wire:click="edit({{ $loop->index }})" class="min-w-0 flex-1 truncate text-left font-mono text-sm text-ink-2 transition-colors hover:text-accent-text">{{ $e['name'] }}</button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -119,10 +119,10 @@
|
||||||
{{-- Row actions --}}
|
{{-- Row actions --}}
|
||||||
<div class="flex shrink-0 items-center gap-1 lg:justify-end">
|
<div class="flex shrink-0 items-center gap-1 lg:justify-end">
|
||||||
@unless ($isDir)
|
@unless ($isDir)
|
||||||
<x-btn variant="ghost" wire:click="download(@js($e['name']))">Download</x-btn>
|
<x-btn variant="ghost" wire:click="download({{ $loop->index }})">Download</x-btn>
|
||||||
<x-btn variant="ghost" wire:click="edit(@js($e['name']))">Bearbeiten</x-btn>
|
<x-btn variant="ghost" wire:click="edit({{ $loop->index }})">Bearbeiten</x-btn>
|
||||||
@endunless
|
@endunless
|
||||||
<x-btn variant="ghost-danger" wire:click="confirmDelete(@js($e['name']))">Löschen</x-btn>
|
<x-btn variant="ghost-danger" wire:click="confirmDelete({{ $loop->index }})">Löschen</x-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@
|
||||||
</div>
|
</div>
|
||||||
<p class="truncate font-mono text-[11px] text-ink-3">{{ $key['fingerprint'] }}</p>
|
<p class="truncate font-mono text-[11px] text-ink-3">{{ $key['fingerprint'] }}</p>
|
||||||
</div>
|
</div>
|
||||||
<x-btn variant="ghost-danger" icon wire:click="confirmKeyRemoval(@js($key['fingerprint']), @js($key['comment']))" title="Schlüssel entfernen">
|
<x-btn variant="ghost-danger" icon wire:click="confirmKeyRemoval({{ $loop->index }})" title="Schlüssel entfernen">
|
||||||
<x-icon name="trash" class="h-3.5 w-3.5" />
|
<x-icon name="trash" class="h-3.5 w-3.5" />
|
||||||
</x-btn>
|
</x-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,9 @@
|
||||||
|
|
||||||
{{-- R5: wire to wire-elements/modal --}}
|
{{-- R5: wire to wire-elements/modal --}}
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<x-btn variant="secondary" wire:click="confirm('start', @js($svc['name']))" wire:loading.attr="disabled" :disabled="$svc['status'] === 'online'">Starten</x-btn>
|
<x-btn variant="secondary" wire:click="confirm('start', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'online'">Starten</x-btn>
|
||||||
<x-btn variant="secondary" wire:click="confirm('stop', @js($svc['name']))" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">Stopp</x-btn>
|
<x-btn variant="secondary" wire:click="confirm('stop', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">Stopp</x-btn>
|
||||||
<x-btn variant="accent" wire:click="confirm('restart', @js($svc['name']))" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">Neustart</x-btn>
|
<x-btn variant="accent" wire:click="confirm('restart', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">Neustart</x-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
33
rules.md
33
rules.md
|
|
@ -22,6 +22,8 @@
|
||||||
- [ ] **R10** — Reused existing `@theme` tokens + Blade components (no ad-hoc colors/widgets).
|
- [ ] **R10** — Reused existing `@theme` tokens + Blade components (no ad-hoc colors/widgets).
|
||||||
- [ ] **R11** — Records exposed in URLs are addressed by **UUID**, never the integer PK.
|
- [ ] **R11** — Records exposed in URLs are addressed by **UUID**, never the integer PK.
|
||||||
- [ ] **R12** — every touched page loads in a **browser** at **HTTP 200** with **zero console errors** (the **loaded** state of `wire:init` pages, not just the skeleton).
|
- [ ] **R12** — every touched page loads in a **browser** at **HTTP 200** with **zero console errors** (the **loaded** state of `wire:init` pages, not just the skeleton).
|
||||||
|
- [ ] **R13** — route **paths + names are English** (`/settings`, not `/einstellungen`); German only in the visible label.
|
||||||
|
- [ ] **R14** — fonts are **self-hosted locally** (`resources/fonts/*.woff2` bundled by Vite + `@font-face` in `app.css`) — **no Google Fonts / CDN `<link>` or `@import`**.
|
||||||
- [ ] **Secrets** — `git status` shows **no** `.env*`, token, key, or home-dir dotfile staged.
|
- [ ] **Secrets** — `git status` shows **no** `.env*`, token, key, or home-dir dotfile staged.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -393,6 +395,37 @@ Route::get('/dateien', ...); // → use /files
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## R14 — Fonts are self-hosted locally, never loaded from a CDN
|
||||||
|
|
||||||
|
**Why.** A self-hosted control plane runs in private/air-gapped networks and must not leak
|
||||||
|
requests to third parties or break when the internet is unreachable. Every font ships **with the
|
||||||
|
app** — `.woff2` files in `public/fonts/`, declared via `@font-face` in `resources/css/app.css`.
|
||||||
|
**No** `<link href="fonts.googleapis.com">`, no `@import url(...)` from a CDN, no `fonts.gstatic.com`.
|
||||||
|
|
||||||
|
✅ **Correct**
|
||||||
|
```css
|
||||||
|
/* resources/css/app.css — local file in resources/fonts/, bundled + hashed by Vite
|
||||||
|
(relative url so it resolves on the Vite dev server AND in the prod build) */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Space Grotesk';
|
||||||
|
font-weight: 300 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/space-grotesk.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> Use a **relative** `url('../fonts/…')` (not `/fonts/…`): an absolute path resolves against the
|
||||||
|
> Vite dev-server origin in dev → 404. Relative lets Vite resolve + emit the asset for both modes.
|
||||||
|
|
||||||
|
❌ **Forbidden**
|
||||||
|
```html
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk"> <!-- ❌ CDN -->
|
||||||
|
```
|
||||||
|
```css
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Chakra+Petch'); /* ❌ CDN import */
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Appendix — Secret hygiene
|
## Appendix — Secret hygiene
|
||||||
|
|
||||||
The project lives in `/home/nexxo/clusev` (its own git repo). The Gitea push token lives in
|
The project lives in `/home/nexxo/clusev` (its own git repo). The Gitea push token lives in
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue