feat(dashboard): rich overview with Chart.js (customer-portal template)
- Chart.js as an Alpine island (x-ui.chart): configs are PHP arrays; colours use token: strings resolved from CSS design tokens at runtime (R3). Respects reduced-motion; IBM Plex chart defaults. - Tokens: soft --radius-xl (20px), --success-bright for dots/rings/charts. Staggered 'rise' reveal keyframe. Global toast in the app shell. - Overview rebuilt to the customer template: storage doughnut ring, availability sparkline, KPI tiles, upsell, cloud card, interactive onboarding checklist (Alpine), backups, modules (Lucide icons — no emoji, R9), activity feed. - Locale-aware fixture display: Carbon isoFormat dates + Number::format sizes so the EN dashboard is not mixed-language (R16). - 14 Pest tests green; R12 browser: /dashboard 0 console errors with Chart.js; R7 no overflow at 375/768/1280. Reviewed with Codex (R15) — clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
2b08b072fe
commit
499adaff00
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Number;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -10,34 +12,111 @@ class Dashboard extends Component
|
|||
{
|
||||
public function render()
|
||||
{
|
||||
// Fixture data until the provisioning engine (separate handoff) provides
|
||||
// real instances / provisioning_runs. The dashboard is a pure view (§12.3).
|
||||
$storageUsed = 172;
|
||||
$storageQuota = 250;
|
||||
// Locale-aware date / number formatting for the fixture values below.
|
||||
$locale = app()->getLocale();
|
||||
$date = fn (string $iso, string $fmt) => Carbon::parse($iso)->locale($locale)->isoFormat($fmt);
|
||||
$gb = fn (float $v) => Number::format($v, precision: 1, locale: $locale).' GB';
|
||||
// Fixture data until the provisioning engine (separate handoff) supplies
|
||||
// real instances / metrics. Tenant-specific values stand in for DB rows.
|
||||
$storageUsed = 235;
|
||||
$storageQuota = 500;
|
||||
$storagePercent = (int) round($storageUsed / max(1, $storageQuota) * 100);
|
||||
$storageTone = $storagePercent >= 90 ? 'danger' : ($storagePercent >= 85 ? 'warning' : 'accent');
|
||||
|
||||
// ~30 days of availability, gently trending near 100%.
|
||||
$availability = [99.95, 99.97, 99.96, 99.98, 99.99, 99.97, 99.98, 99.99, 99.99, 99.98,
|
||||
99.97, 99.99, 100, 99.98, 99.99, 99.99, 99.97, 99.98, 99.99, 100,
|
||||
99.98, 99.99, 99.99, 99.98, 100, 99.99, 99.98, 99.99, 100, 99.98];
|
||||
|
||||
return view('livewire.dashboard', [
|
||||
'storageUsed' => $storageUsed,
|
||||
'storageQuota' => $storageQuota,
|
||||
'storagePercent' => $storagePercent,
|
||||
'storageTone' => $storageTone,
|
||||
'activeUsers' => 14,
|
||||
'lastBackup' => __('dashboard.today_time', ['time' => '04:12']),
|
||||
'uptime' => '99.98',
|
||||
'instanceUrl' => 'https://kunde-demo.clupilot.com',
|
||||
'instanceStatus' => 'active',
|
||||
'provisioning' => false,
|
||||
'steps' => [
|
||||
['label' => __('dashboard.steps.validate'), 'state' => 'done'],
|
||||
['label' => __('dashboard.steps.provision'), 'state' => 'done'],
|
||||
['label' => __('dashboard.steps.configure'), 'state' => 'done'],
|
||||
['label' => __('dashboard.steps.acceptance'), 'state' => 'done'],
|
||||
'usersActive' => 8,
|
||||
'usersQuota' => 20,
|
||||
'lastBackup' => '03:12',
|
||||
'availability' => Number::format(99.98, precision: 2, locale: $locale),
|
||||
|
||||
'storageChart' => [
|
||||
'type' => 'doughnut',
|
||||
'data' => [
|
||||
'datasets' => [[
|
||||
'data' => [$storagePercent, 100 - $storagePercent],
|
||||
'backgroundColor' => ['token:accent', 'token:surface-2'],
|
||||
'borderWidth' => 0,
|
||||
]],
|
||||
],
|
||||
'options' => [
|
||||
'cutout' => '76%',
|
||||
'plugins' => [
|
||||
'legend' => ['display' => false],
|
||||
'tooltip' => ['enabled' => false],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'availabilityChart' => [
|
||||
'type' => 'line',
|
||||
'data' => [
|
||||
'labels' => array_fill(0, count($availability), ''),
|
||||
'datasets' => [[
|
||||
'data' => $availability,
|
||||
'borderColor' => 'token:success-bright',
|
||||
'backgroundColor' => 'token:success-bright/0.12',
|
||||
'fill' => true,
|
||||
'tension' => 0.4,
|
||||
'pointRadius' => 0,
|
||||
'borderWidth' => 2,
|
||||
]],
|
||||
],
|
||||
'options' => [
|
||||
'scales' => [
|
||||
'x' => ['display' => false],
|
||||
'y' => ['display' => false, 'min' => 99.9, 'max' => 100],
|
||||
],
|
||||
'plugins' => [
|
||||
'legend' => ['display' => false],
|
||||
'tooltip' => ['enabled' => false],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'cloud' => [
|
||||
'name' => 'Cloud der Kanzlei Berger',
|
||||
'domain' => 'cloud.kanzlei-berger.at',
|
||||
'package' => __('dashboard.cloud.plan_line', ['plan' => 'CluPilot Cloud Team', 'price' => 179]),
|
||||
'since' => $date('2026-03-14', 'LL'),
|
||||
'location' => __('dashboard.cloud.datacenter'),
|
||||
'version' => 'Nextcloud 31.0.4',
|
||||
],
|
||||
|
||||
'onboarding' => [
|
||||
['label' => __('dashboard.onboarding.instance'), 'done' => true, 'date' => $date('2026-03-14', 'D. MMMM')],
|
||||
['label' => __('dashboard.onboarding.domain'), 'done' => true, 'date' => $date('2026-03-15', 'D. MMMM')],
|
||||
['label' => __('dashboard.onboarding.branding'), 'done' => true, 'date' => $date('2026-03-15', 'D. MMMM')],
|
||||
['label' => __('dashboard.onboarding.users'), 'done' => true, 'date' => $date('2026-03-16', 'D. MMMM')],
|
||||
['label' => __('dashboard.onboarding.migration'), 'done' => false, 'date' => null],
|
||||
['label' => __('dashboard.onboarding.training'), 'done' => false, 'date' => null],
|
||||
['label' => __('dashboard.onboarding.docs'), 'done' => false, 'date' => null],
|
||||
],
|
||||
|
||||
'backups' => [
|
||||
['when' => __('dashboard.today').', 03:12', 'size' => $gb(18.4)],
|
||||
['when' => __('dashboard.yesterday').', 03:09', 'size' => $gb(18.4)],
|
||||
['when' => $date('2026-07-21', 'D. MMMM').', 03:11', 'size' => $gb(18.3)],
|
||||
],
|
||||
|
||||
'modules' => [
|
||||
['icon' => 'cloud', 'name' => 'Nextcloud + Collabora', 'desc' => __('dashboard.modules.nextcloud'), 'status' => 'active'],
|
||||
['icon' => 'shield-check', 'name' => 'Vaultwarden', 'desc' => __('dashboard.modules.vaultwarden'), 'status' => 'active'],
|
||||
['icon' => 'receipt', 'name' => 'Paperless-ngx', 'desc' => __('dashboard.modules.paperless'), 'status' => 'available'],
|
||||
['icon' => 'pen', 'name' => __('dashboard.modules.signature_name'), 'desc' => __('dashboard.modules.signature'), 'status' => 'soon'],
|
||||
],
|
||||
|
||||
'activity' => [
|
||||
['label' => __('dashboard.activity_items.backup_ok'), 'time' => '04:12'],
|
||||
['label' => __('dashboard.activity_items.user_added'), 'time' => __('dashboard.yesterday')],
|
||||
['label' => __('dashboard.activity_items.login'), 'time' => __('dashboard.yesterday')],
|
||||
['icon' => 'download', 'text' => __('dashboard.activity_items.backup_ok'), 'time' => '03:12'],
|
||||
['icon' => 'shield-check', 'text' => __('dashboard.activity_items.security'), 'time' => '04:01'],
|
||||
['icon' => 'users', 'text' => __('dashboard.activity_items.upload'), 'time' => '07:56'],
|
||||
['icon' => 'shield-check', 'text' => __('dashboard.activity_items.cert'), 'time' => __('dashboard.yesterday').' 22:10'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@
|
|||
return [
|
||||
'title' => 'Übersicht',
|
||||
'subtitle' => 'Status Ihrer Cloud auf einen Blick.',
|
||||
'greeting' => 'Guten Morgen, :name',
|
||||
'system_ok' => 'Alle Systeme normal',
|
||||
'as_of' => 'Zustand Ihrer Cloud auf einen Blick — Stand heute, :time Uhr.',
|
||||
|
||||
'nav' => [
|
||||
'overview' => 'Übersicht',
|
||||
'cloud' => 'Meine Cloud',
|
||||
'users' => 'Nutzer',
|
||||
'users' => 'Benutzer',
|
||||
'backups' => 'Backups',
|
||||
'invoices' => 'Rechnungen',
|
||||
'support' => 'Support',
|
||||
|
|
@ -17,19 +20,84 @@ return [
|
|||
'close_nav' => 'Navigation schließen',
|
||||
'logout' => 'Abmelden',
|
||||
|
||||
'today' => 'Heute',
|
||||
'yesterday' => 'Gestern',
|
||||
|
||||
'kpi' => [
|
||||
'storage' => 'Speicher',
|
||||
'users' => 'Aktive Nutzer',
|
||||
'users' => 'Benutzer',
|
||||
'backup' => 'Letztes Backup',
|
||||
'uptime' => 'Verfügbarkeit',
|
||||
'availability' => 'Verfügbarkeit · 30 Tage',
|
||||
],
|
||||
'occupied' => 'belegt',
|
||||
'storage_week' => '+3,4 GB diese Woche',
|
||||
'users_detail' => '3 Gruppen · 2 Gastzugänge',
|
||||
'backup_verified' => 'heute · verschlüsselt & geprüft',
|
||||
|
||||
'upsell' => [
|
||||
'title' => 'Ihr Speicher ist zu :percent % belegt.',
|
||||
'body' => 'Jederzeit erweiterbar — +100 GB für 10 € pro Monat, monatlich kündbar.',
|
||||
'cta' => 'Speicher erweitern',
|
||||
],
|
||||
|
||||
'cloud' => [
|
||||
'title' => 'Meine Cloud',
|
||||
'open' => 'Cloud öffnen',
|
||||
'package' => 'Paket',
|
||||
'status' => 'Status',
|
||||
'location' => 'Serverstandort',
|
||||
'version' => 'Version',
|
||||
'active_since' => 'Aktiv seit :date',
|
||||
'current' => 'aktuell',
|
||||
'plan_line' => ':plan · :price €/Monat',
|
||||
'datacenter' => 'EU · Rechenzentrum Falkenstein',
|
||||
],
|
||||
|
||||
'onboarding' => [
|
||||
'title' => 'Einrichtung Ihrer Cloud',
|
||||
'progress' => ':done von :total erledigt',
|
||||
'review' => 'Jetzt prüfen',
|
||||
'next' => 'Weiter',
|
||||
'done_toast' => 'Erledigt — schöner Fortschritt!',
|
||||
'instance' => 'Instanz erstellt & gestartet',
|
||||
'domain' => 'Eigene Domain verbunden',
|
||||
'branding' => 'Kanzlei-Logo & Farben hinterlegt',
|
||||
'users' => '8 Benutzer angelegt, Rechte gesetzt',
|
||||
'migration' => 'Datenübernahme aus Dropbox freigeben',
|
||||
'training' => 'Einschulungstermin wählen',
|
||||
'docs' => 'Dokumentationsmappe herunterladen',
|
||||
],
|
||||
|
||||
'backups' => [
|
||||
'title' => 'Backups',
|
||||
'schedule' => 'täglich 03:00',
|
||||
'restore' => 'Wiederherstellen',
|
||||
'note' => 'Clientseitig verschlüsselt, getrennt gelagert. Letzter Wiederherstellungstest: 01.07. — erfolgreich.',
|
||||
'restore_toast' => 'Wiederherstellung: wir melden uns binnen 1 Std.',
|
||||
],
|
||||
|
||||
'modules' => [
|
||||
'title' => 'Ihre Module',
|
||||
'addon' => 'zubuchbar',
|
||||
'active' => 'Aktiv',
|
||||
'add' => 'Hinzubuchen',
|
||||
'soon' => 'In Vorbereitung',
|
||||
'nextcloud' => 'Dateien, Office, Kalender, Talk',
|
||||
'vaultwarden' => 'Team-Passwortmanager · Zusatzmodul 19 €/Monat',
|
||||
'paperless' => 'Digitales Dokumentenarchiv · ab 39 €/Monat',
|
||||
'signature_name' => 'Digitale Signatur',
|
||||
'signature' => 'Rechtsgültige Unterschriften · ab 29 €/Monat',
|
||||
'add_toast' => 'Zusatzmodul zubuchbar — wir beraten Sie gern.',
|
||||
],
|
||||
|
||||
'instance' => 'Instanz',
|
||||
'open_cloud' => 'Cloud öffnen',
|
||||
'provisioning' => 'Bereitstellung',
|
||||
'activity' => 'Aktivität',
|
||||
'today_time' => 'Heute :time',
|
||||
'yesterday' => 'Gestern',
|
||||
'activity_live' => 'live',
|
||||
'activity_items' => [
|
||||
'backup_ok' => 'Backup abgeschlossen — 18,4 GB',
|
||||
'security' => 'Sicherheitsupdates eingespielt',
|
||||
'upload' => 'M. Huber hat 14 Dateien hochgeladen',
|
||||
'cert' => 'SSL-Zertifikat automatisch erneuert',
|
||||
],
|
||||
|
||||
'status' => [
|
||||
'active' => 'Aktiv',
|
||||
|
|
@ -38,23 +106,8 @@ return [
|
|||
'failed' => 'Fehlgeschlagen',
|
||||
],
|
||||
|
||||
'steps' => [
|
||||
'validate' => 'Bestellung geprüft',
|
||||
'provision' => 'Instanz erstellt',
|
||||
'configure' => 'Cloud konfiguriert',
|
||||
'acceptance' => 'Abnahme bestanden',
|
||||
],
|
||||
|
||||
'step_state' => [
|
||||
'pending' => 'Ausstehend',
|
||||
'running' => 'Läuft',
|
||||
'done' => 'Abgeschlossen',
|
||||
'failed' => 'Fehlgeschlagen',
|
||||
],
|
||||
|
||||
'activity_items' => [
|
||||
'backup_ok' => 'Backup erfolgreich abgeschlossen',
|
||||
'user_added' => 'Neuer Nutzer hinzugefügt',
|
||||
'login' => 'Anmeldung am Portal',
|
||||
],
|
||||
'instance' => 'Instanz',
|
||||
'open_cloud' => 'Cloud öffnen',
|
||||
'provisioning' => 'Bereitstellung',
|
||||
'today_time' => 'Heute :time',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
return [
|
||||
'title' => 'Overview',
|
||||
'subtitle' => 'Your cloud status at a glance.',
|
||||
'greeting' => 'Good morning, :name',
|
||||
'system_ok' => 'All systems normal',
|
||||
'as_of' => 'Your cloud status at a glance — as of today, :time.',
|
||||
|
||||
'nav' => [
|
||||
'overview' => 'Overview',
|
||||
|
|
@ -17,19 +20,84 @@ return [
|
|||
'close_nav' => 'Close navigation',
|
||||
'logout' => 'Sign out',
|
||||
|
||||
'today' => 'Today',
|
||||
'yesterday' => 'Yesterday',
|
||||
|
||||
'kpi' => [
|
||||
'storage' => 'Storage',
|
||||
'users' => 'Active users',
|
||||
'users' => 'Users',
|
||||
'backup' => 'Last backup',
|
||||
'uptime' => 'Uptime',
|
||||
'availability' => 'Uptime · 30 days',
|
||||
],
|
||||
'occupied' => 'used',
|
||||
'storage_week' => '+3.4 GB this week',
|
||||
'users_detail' => '3 groups · 2 guest accounts',
|
||||
'backup_verified' => 'today · encrypted & verified',
|
||||
|
||||
'upsell' => [
|
||||
'title' => 'Your storage is :percent% used.',
|
||||
'body' => 'Expandable any time — +100 GB for €10 per month, cancel monthly.',
|
||||
'cta' => 'Add storage',
|
||||
],
|
||||
|
||||
'cloud' => [
|
||||
'title' => 'My cloud',
|
||||
'open' => 'Open cloud',
|
||||
'package' => 'Plan',
|
||||
'status' => 'Status',
|
||||
'location' => 'Server location',
|
||||
'version' => 'Version',
|
||||
'active_since' => 'Active since :date',
|
||||
'current' => 'up to date',
|
||||
'plan_line' => ':plan · €:price/mo',
|
||||
'datacenter' => 'EU · Falkenstein data center',
|
||||
],
|
||||
|
||||
'onboarding' => [
|
||||
'title' => 'Setting up your cloud',
|
||||
'progress' => ':done of :total done',
|
||||
'review' => 'Review now',
|
||||
'next' => 'Next',
|
||||
'done_toast' => 'Done — nice progress!',
|
||||
'instance' => 'Instance created & started',
|
||||
'domain' => 'Custom domain connected',
|
||||
'branding' => 'Firm logo & colors applied',
|
||||
'users' => '8 users created, permissions set',
|
||||
'migration' => 'Approve data import from Dropbox',
|
||||
'training' => 'Choose a training appointment',
|
||||
'docs' => 'Download the documentation pack',
|
||||
],
|
||||
|
||||
'backups' => [
|
||||
'title' => 'Backups',
|
||||
'schedule' => 'daily 03:00',
|
||||
'restore' => 'Restore',
|
||||
'note' => 'Client-side encrypted, stored separately. Last restore test: 01 Jul — successful.',
|
||||
'restore_toast' => 'Restore: we will get back to you within 1 hr.',
|
||||
],
|
||||
|
||||
'modules' => [
|
||||
'title' => 'Your modules',
|
||||
'addon' => 'add-on',
|
||||
'active' => 'Active',
|
||||
'add' => 'Add',
|
||||
'soon' => 'Coming soon',
|
||||
'nextcloud' => 'Files, office, calendar, Talk',
|
||||
'vaultwarden' => 'Team password manager · add-on €19/month',
|
||||
'paperless' => 'Digital document archive · from €39/month',
|
||||
'signature_name' => 'Digital signature',
|
||||
'signature' => 'Legally binding signatures · from €29/month',
|
||||
'add_toast' => 'Add-on available — we are happy to advise you.',
|
||||
],
|
||||
|
||||
'instance' => 'Instance',
|
||||
'open_cloud' => 'Open cloud',
|
||||
'provisioning' => 'Provisioning',
|
||||
'activity' => 'Activity',
|
||||
'today_time' => 'Today :time',
|
||||
'yesterday' => 'Yesterday',
|
||||
'activity_live' => 'live',
|
||||
'activity_items' => [
|
||||
'backup_ok' => 'Backup completed — 18.4 GB',
|
||||
'security' => 'Security updates applied',
|
||||
'upload' => 'M. Huber uploaded 14 files',
|
||||
'cert' => 'SSL certificate auto-renewed',
|
||||
],
|
||||
|
||||
'status' => [
|
||||
'active' => 'Active',
|
||||
|
|
@ -38,23 +106,8 @@ return [
|
|||
'failed' => 'Failed',
|
||||
],
|
||||
|
||||
'steps' => [
|
||||
'validate' => 'Order validated',
|
||||
'provision' => 'Instance created',
|
||||
'configure' => 'Cloud configured',
|
||||
'acceptance' => 'Acceptance passed',
|
||||
],
|
||||
|
||||
'step_state' => [
|
||||
'pending' => 'Pending',
|
||||
'running' => 'Running',
|
||||
'done' => 'Done',
|
||||
'failed' => 'Failed',
|
||||
],
|
||||
|
||||
'activity_items' => [
|
||||
'backup_ok' => 'Backup completed successfully',
|
||||
'user_added' => 'New user added',
|
||||
'login' => 'Portal sign-in',
|
||||
],
|
||||
'instance' => 'Instance',
|
||||
'open_cloud' => 'Open cloud',
|
||||
'provisioning' => 'Provisioning',
|
||||
'today_time' => 'Today :time',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"dependencies": {
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@fontsource/ibm-plex-sans": "^5.3.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"laravel-echo": "^2.4.0",
|
||||
"pusher-js": "^8.6.0"
|
||||
},
|
||||
|
|
@ -123,6 +124,12 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||
|
|
@ -703,6 +710,18 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"dependencies": {
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@fontsource/ibm-plex-sans": "^5.3.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"laravel-echo": "^2.4.0",
|
||||
"pusher-js": "^8.6.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
|
||||
/* Status semantics — only in badges/alerts, never a second decorative tone */
|
||||
--success: #067a48; --success-bg: #ecfdf3; --success-border: #abefc6;
|
||||
--success-bright: #30b15c; /* dots/rings/charts only (large marks, not small text) */
|
||||
--warning: #b54708; --warning-bg: #fffaeb; --warning-border: #fedf89;
|
||||
--danger: #b42318; --danger-bg: #fef3f2; --danger-border: #fecdca;
|
||||
--info: #175cd3; --info-bg: #eff8ff; --info-border: #b2ddff;
|
||||
|
|
@ -49,7 +50,7 @@
|
|||
--tracking-label: 0.06em;
|
||||
|
||||
/* Radius / shadow / motion / focus */
|
||||
--radius-sm: 6px; --radius: 8px; --radius-lg: 12px; --radius-pill: 999px;
|
||||
--radius-sm: 6px; --radius: 8px; --radius-lg: 12px; --radius-xl: 20px; --radius-pill: 999px;
|
||||
|
||||
--shadow-xs: 0 1px 2px rgba(16, 24, 40, .05);
|
||||
--shadow-sm: 0 1px 3px rgba(16, 24, 40, .10), 0 1px 2px rgba(16, 24, 40, .06);
|
||||
|
|
|
|||
|
|
@ -1,49 +1,95 @@
|
|||
import './echo';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
// Resolve "token:name" / "token:name/0.12" strings in a chart config to real
|
||||
// colours pulled from the CSS design tokens (keeps charts on-palette, R3).
|
||||
function tokenColor(spec, css) {
|
||||
const [name, alpha] = spec.split('/');
|
||||
const value = css.getPropertyValue('--' + name.trim()).trim();
|
||||
if (!value) return spec;
|
||||
if (alpha && value.startsWith('#')) {
|
||||
let hex = value.slice(1);
|
||||
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveTokens(node, css) {
|
||||
if (typeof node === 'string') return node.startsWith('token:') ? tokenColor(node.slice(6), css) : node;
|
||||
if (Array.isArray(node)) return node.map((n) => resolveTokens(n, css));
|
||||
if (node && typeof node === 'object') {
|
||||
const out = {};
|
||||
for (const key of Object.keys(node)) out[key] = resolveTokens(node[key], css);
|
||||
return out;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// Segmented OTP input behaviour (used by <x-ui.otp-input>). Alpine ships with
|
||||
// Livewire; register on alpine:init.
|
||||
document.addEventListener('alpine:init', () => {
|
||||
// ── Segmented OTP input ──────────────────────────────────────────────
|
||||
window.Alpine.data('otpInput', ({ length = 6 } = {}) => ({
|
||||
length,
|
||||
digits: Array(length).fill(''),
|
||||
|
||||
get value() {
|
||||
return this.digits.join('');
|
||||
},
|
||||
|
||||
cells() {
|
||||
return this.$root.querySelectorAll('input[inputmode="numeric"]');
|
||||
},
|
||||
|
||||
focusCell(i) {
|
||||
const cell = this.cells()[i];
|
||||
if (cell) cell.focus();
|
||||
},
|
||||
|
||||
onInput(i, e) {
|
||||
const digit = e.target.value.replace(/\D/g, '').slice(-1);
|
||||
this.digits[i] = digit;
|
||||
if (digit && i < this.length - 1) this.focusCell(i + 1);
|
||||
this.maybeSubmit();
|
||||
},
|
||||
|
||||
onBackspace(i) {
|
||||
if (!this.digits[i] && i > 0) this.focusCell(i - 1);
|
||||
},
|
||||
|
||||
onPaste(e) {
|
||||
const text = (e.clipboardData.getData('text') || '').replace(/\D/g, '').slice(0, this.length);
|
||||
for (let i = 0; i < this.length; i++) this.digits[i] = text[i] || '';
|
||||
this.focusCell(Math.min(text.length, this.length - 1));
|
||||
this.maybeSubmit();
|
||||
},
|
||||
|
||||
maybeSubmit() {
|
||||
if (this.value.length === this.length) {
|
||||
// Wait for Alpine to flush :value into the hidden input, otherwise
|
||||
// requestSubmit() can post a stale/short code.
|
||||
this.$nextTick(() => this.$root.closest('form')?.requestSubmit());
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Chart.js island ──────────────────────────────────────────────────
|
||||
window.Alpine.data('chart', (config) => ({
|
||||
instance: null,
|
||||
init() {
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
Chart.defaults.font.family = css.getPropertyValue('--font-sans').trim() || 'sans-serif';
|
||||
Chart.defaults.color = css.getPropertyValue('--text-muted').trim() || '#667085';
|
||||
Chart.defaults.borderColor = css.getPropertyValue('--border').trim() || '#e4e7ec';
|
||||
|
||||
const cfg = resolveTokens(config, css);
|
||||
cfg.options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: prefersReducedMotion() ? false : cfg.options?.animation,
|
||||
...cfg.options,
|
||||
};
|
||||
this.instance = new Chart(this.$refs.canvas, cfg);
|
||||
},
|
||||
destroy() {
|
||||
this.instance?.destroy();
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
@props([
|
||||
// Chart.js config as a PHP array. Colours may use "token:accent" /
|
||||
// "token:accent/0.12" strings, resolved from CSS design tokens at runtime.
|
||||
'config',
|
||||
'label' => null,
|
||||
])
|
||||
<div
|
||||
x-data="chart(@js($config))"
|
||||
@if ($label) role="img" aria-label="{{ $label }}" @endif
|
||||
{{ $attributes->merge(['class' => 'relative']) }}
|
||||
>
|
||||
<canvas x-ref="canvas"></canvas>
|
||||
</div>
|
||||
|
|
@ -13,6 +13,12 @@
|
|||
'chevron-down' => '<path d="m6 9 6 6 6-6"/>',
|
||||
'external-link'=> '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
||||
'shield-check' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>',
|
||||
'shield' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
|
||||
'pen' => '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
|
||||
'download' => '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
|
||||
'alert-triangle' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/>',
|
||||
'check' => '<polyline points="20 6 9 17 4 12"/>',
|
||||
'plus' => '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||
];
|
||||
$body = $icons[$name] ?? '';
|
||||
@endphp
|
||||
|
|
|
|||
|
|
@ -85,6 +85,20 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Global toast — any element can trigger it with $dispatch('notify', { message }) --}}
|
||||
<div
|
||||
x-data="{ show: false, msg: '', timer: null }"
|
||||
@notify.window="msg = $event.detail.message; show = true; clearTimeout(timer); timer = setTimeout(() => show = false, 2400)"
|
||||
x-show="show"
|
||||
x-cloak
|
||||
x-transition.opacity.duration.200ms
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-pill bg-ink px-5 py-3 text-sm font-medium text-on-accent shadow-md"
|
||||
>
|
||||
<span x-text="msg"></span>
|
||||
</div>
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,61 +1,212 @@
|
|||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('dashboard.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('dashboard.subtitle') }}</p>
|
||||
<div class="space-y-4" x-data="{ msgs: @js(['addon' => __('dashboard.modules.add_toast'), 'restore' => __('dashboard.backups.restore_toast')]) }">
|
||||
{{-- Header --}}
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 animate-rise">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('dashboard.greeting', ['name' => auth()->user()->name]) }}</h1>
|
||||
<span class="ml-auto inline-flex items-center gap-2 rounded-pill bg-success-bg px-3.5 py-1.5 text-xs font-semibold text-success">
|
||||
<span class="size-2 rounded-pill bg-success-bright" aria-hidden="true"></span>{{ __('dashboard.system_ok') }}
|
||||
</span>
|
||||
<p class="w-full text-sm text-muted">{{ __('dashboard.as_of', ['time' => '08:42']) }}</p>
|
||||
</div>
|
||||
|
||||
{{-- KPI row: 1 → 2 → 4 (R7) --}}
|
||||
{{-- KPI row --}}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<x-ui.stat-tile
|
||||
:label="__('dashboard.kpi.storage')"
|
||||
:value="$storageUsed"
|
||||
:unit="'/ '.$storageQuota.' GB'"
|
||||
:percent="$storagePercent"
|
||||
:tone="$storageTone"
|
||||
/>
|
||||
<x-ui.stat-tile :label="__('dashboard.kpi.users')" :value="$activeUsers" />
|
||||
<x-ui.stat-tile :label="__('dashboard.kpi.backup')" :value="$lastBackup" />
|
||||
<x-ui.stat-tile :label="__('dashboard.kpi.uptime')" :value="$uptime" unit="%" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{{-- Instance card --}}
|
||||
<x-ui.card class="lg:col-span-2">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.instance') }}</p>
|
||||
<p class="mt-1 font-mono text-sm text-ink">{{ $instanceUrl }}</p>
|
||||
<div class="mt-2">
|
||||
<x-ui.badge :status="$instanceStatus">{{ __('dashboard.status.'.$instanceStatus) }}</x-ui.badge>
|
||||
{{-- Storage ring --}}
|
||||
<div class="flex items-center gap-4 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="relative size-24 shrink-0" wire:ignore>
|
||||
<x-ui.chart :config="$storageChart" class="size-24" :label="__('dashboard.kpi.storage')" />
|
||||
<div class="pointer-events-none absolute inset-0 grid place-items-center text-center">
|
||||
<div>
|
||||
<span class="font-mono text-lg font-semibold text-ink">{{ $storagePercent }}%</span>
|
||||
<span class="block text-[0.62rem] text-muted">{{ __('dashboard.occupied') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="{{ $instanceUrl }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center justify-center gap-2 rounded bg-accent-active px-4 py-2.5 text-sm font-semibold text-on-accent transition hover:bg-accent-press"
|
||||
>
|
||||
<x-ui.icon name="external-link" class="size-4" />
|
||||
{{ __('dashboard.open_cloud') }}
|
||||
</a>
|
||||
</div>
|
||||
</x-ui.card>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.kpi.storage') }}</p>
|
||||
<p class="mt-1 font-mono text-xl font-semibold text-ink">{{ $storageUsed }}<span class="ml-1 text-sm font-normal text-muted">/ {{ $storageQuota }} GB</span></p>
|
||||
<p class="mt-0.5 text-xs text-muted">{{ __('dashboard.storage_week') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Provisioning progress (live via Reverb once the engine lands) --}}
|
||||
<x-ui.card :title="__('dashboard.provisioning')">
|
||||
<x-ui.progress-stepper :steps="$steps" />
|
||||
</x-ui.card>
|
||||
{{-- Users --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.kpi.users') }}</p>
|
||||
<p class="mt-2 font-mono text-2xl font-semibold text-ink">{{ $usersActive }}<span class="ml-1 text-sm font-normal text-muted">/ {{ $usersQuota }}</span></p>
|
||||
<p class="mt-1 text-xs text-muted">{{ __('dashboard.users_detail') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Last backup --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.kpi.backup') }}</p>
|
||||
<p class="mt-2 flex items-center gap-1.5 font-mono text-2xl font-semibold text-success">
|
||||
{{ $lastBackup }}<x-ui.icon name="check" class="size-5" />
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted">{{ __('dashboard.backup_verified') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Availability sparkline --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:240ms]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.kpi.availability') }}</p>
|
||||
<p class="mt-2 font-mono text-2xl font-semibold text-ink">{{ $availability }}<span class="ml-1 text-sm font-normal text-muted">%</span></p>
|
||||
<div class="mt-2 h-8" wire:ignore>
|
||||
<x-ui.chart :config="$availabilityChart" class="h-8" :label="__('dashboard.kpi.availability')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Activity --}}
|
||||
<x-ui.card :title="__('dashboard.activity')">
|
||||
<ul class="divide-y divide-line">
|
||||
@foreach ($activity as $item)
|
||||
<li class="flex items-center justify-between py-2.5 text-sm">
|
||||
<span class="text-body">{{ $item['label'] }}</span>
|
||||
<span class="font-mono text-xs text-faint">{{ $item['time'] }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</x-ui.card>
|
||||
{{-- Upsell --}}
|
||||
<div class="flex flex-wrap items-center gap-4 rounded-xl border border-accent-border bg-accent-subtle px-5 py-4 animate-rise [animation-delay:300ms]">
|
||||
<x-ui.icon name="alert-triangle" class="size-6 shrink-0 text-accent-active" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-ink">{{ __('dashboard.upsell.title', ['percent' => $storagePercent]) }}</p>
|
||||
<p class="text-sm text-muted">{{ __('dashboard.upsell.body') }}</p>
|
||||
</div>
|
||||
<x-ui.button variant="secondary" size="sm" class="ml-auto" @click="$dispatch('notify', { message: msgs.addon })">
|
||||
{{ __('dashboard.upsell.cta') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[1.35fr_1fr] lg:items-start">
|
||||
{{-- Left column --}}
|
||||
<div class="flex flex-col gap-4">
|
||||
{{-- Cloud card --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:360ms]">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="grid size-11 place-items-center rounded-lg bg-ink font-semibold text-on-accent">B</span>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-ink">{{ $cloud['name'] }}</p>
|
||||
<a href="https://{{ $cloud['domain'] }}" target="_blank" rel="noopener noreferrer" class="font-mono text-sm text-accent-text hover:underline">{{ $cloud['domain'] }}</a>
|
||||
</div>
|
||||
<a href="https://{{ $cloud['domain'] }}" target="_blank" rel="noopener noreferrer"
|
||||
class="ml-auto inline-flex items-center gap-2 rounded-pill bg-accent-active px-4 py-2 text-sm font-semibold text-on-accent transition hover:bg-accent-press">
|
||||
<x-ui.icon name="external-link" class="size-4" />{{ __('dashboard.cloud.open') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 border-t border-line pt-4 sm:grid-cols-2">
|
||||
<div><p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.cloud.package') }}</p><p class="mt-1 text-sm text-body">{{ $cloud['package'] }}</p></div>
|
||||
<div><p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.cloud.status') }}</p><p class="mt-1 text-sm text-success">{{ __('dashboard.cloud.active_since', ['date' => $cloud['since']]) }}</p></div>
|
||||
<div><p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.cloud.location') }}</p><p class="mt-1 text-sm text-body">{{ $cloud['location'] }}</p></div>
|
||||
<div><p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.cloud.version') }}</p><p class="mt-1 font-mono text-sm text-body">{{ $cloud['version'] }} · {{ __('dashboard.cloud.current') }}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Onboarding checklist (Alpine) --}}
|
||||
<div
|
||||
class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:420ms]"
|
||||
x-data="{
|
||||
tasks: @js(collect($onboarding)->map(fn ($t) => ['label' => $t['label'], 'done' => $t['done'], 'date' => $t['date']])),
|
||||
total: {{ count($onboarding) }},
|
||||
get doneCount() { return this.tasks.filter(t => t.done).length; },
|
||||
get percent() { return Math.round(this.doneCount / this.total * 100); },
|
||||
complete(i) {
|
||||
if (this.tasks[i].done) return;
|
||||
this.tasks[i].done = true;
|
||||
$dispatch('notify', { message: @js(__('dashboard.onboarding.done_toast')) });
|
||||
},
|
||||
nextIndex() { return this.tasks.findIndex(t => !t.done); },
|
||||
}"
|
||||
>
|
||||
<div class="flex items-baseline gap-3">
|
||||
<h2 class="font-semibold text-ink">{{ __('dashboard.onboarding.title') }}</h2>
|
||||
<span class="ml-auto font-mono text-sm font-semibold text-muted" x-text="doneCount + ' / ' + total"></span>
|
||||
</div>
|
||||
<div class="mt-3 h-1.5 overflow-hidden rounded-pill bg-surface-2">
|
||||
<div class="h-full rounded-pill bg-accent transition-all duration-700" :style="`width: ${percent}%`"></div>
|
||||
</div>
|
||||
<ul class="mt-2">
|
||||
<template x-for="(task, i) in tasks" :key="i">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded-lg px-2.5 py-3 text-left text-sm transition hover:bg-surface-hover"
|
||||
:class="!task.done && i === nextIndex() ? 'bg-accent-subtle' : ''"
|
||||
:disabled="task.done"
|
||||
@click="complete(i)"
|
||||
>
|
||||
<span
|
||||
class="grid size-6 shrink-0 place-items-center rounded-pill border"
|
||||
:class="task.done ? 'border-success-bright bg-success-bright text-on-accent' : (i === nextIndex() ? 'border-accent' : 'border-line-strong')"
|
||||
>
|
||||
<svg x-show="task.done" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" class="size-3.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1" :class="task.done ? 'text-muted line-through' : 'text-body'" x-text="task.label"></span>
|
||||
<span x-show="task.date" class="text-xs text-faint" x-text="task.date"></span>
|
||||
<span x-show="!task.done && i === nextIndex()" class="text-xs font-semibold text-accent-text whitespace-nowrap">{{ __('dashboard.onboarding.review') }} ›</span>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Right column --}}
|
||||
<div class="flex flex-col gap-4">
|
||||
{{-- Backups --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:360ms]">
|
||||
<div class="flex items-center">
|
||||
<h2 class="font-semibold text-ink">{{ __('dashboard.backups.title') }}</h2>
|
||||
<span class="ml-auto rounded-pill bg-success-bg px-2.5 py-1 text-xs font-semibold text-success">{{ __('dashboard.backups.schedule') }}</span>
|
||||
</div>
|
||||
<ul class="mt-2 divide-y divide-line">
|
||||
@foreach ($backups as $b)
|
||||
<li class="flex items-center gap-3 py-2.5 text-sm">
|
||||
<x-ui.icon name="check" class="size-4 text-success" />
|
||||
<span class="font-mono text-xs text-muted">{{ $b['when'] }}</span>
|
||||
<span class="ml-auto font-mono text-xs text-muted">{{ $b['size'] }}</span>
|
||||
<x-ui.button variant="secondary" size="sm" @click="$dispatch('notify', { message: msgs.restore })">{{ __('dashboard.backups.restore') }}</x-ui.button>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<p class="mt-3 flex gap-2 text-xs leading-relaxed text-muted">
|
||||
<x-ui.icon name="shield" class="mt-px size-4 shrink-0 text-accent-active" />
|
||||
<span>{{ __('dashboard.backups.note') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Modules --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:420ms]">
|
||||
<div class="flex items-center">
|
||||
<h2 class="font-semibold text-ink">{{ __('dashboard.modules.title') }}</h2>
|
||||
<span class="ml-auto rounded-pill bg-surface-2 px-2.5 py-1 text-xs font-semibold text-muted">{{ __('dashboard.modules.addon') }}</span>
|
||||
</div>
|
||||
<ul class="mt-2 divide-y divide-line">
|
||||
@foreach ($modules as $m)
|
||||
<li class="flex items-center gap-3 py-2.5 text-sm">
|
||||
<span class="grid size-9 shrink-0 place-items-center rounded-lg bg-surface-2 text-muted"><x-ui.icon :name="$m['icon']" class="size-5" /></span>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium text-ink">{{ $m['name'] }}</p>
|
||||
<p class="text-xs text-muted">{{ $m['desc'] }}</p>
|
||||
</div>
|
||||
@if ($m['status'] === 'active')
|
||||
<span class="ml-auto rounded-pill bg-success-bg px-2.5 py-1 text-xs font-semibold text-success">{{ __('dashboard.modules.active') }}</span>
|
||||
@elseif ($m['status'] === 'available')
|
||||
<x-ui.button variant="secondary" size="sm" class="ml-auto" @click="$dispatch('notify', { message: msgs.addon })">{{ __('dashboard.modules.add') }}</x-ui.button>
|
||||
@else
|
||||
<span class="ml-auto rounded-pill bg-surface-2 px-2.5 py-1 text-xs font-semibold text-muted">{{ __('dashboard.modules.soon') }}</span>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{{-- Activity feed --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:480ms]">
|
||||
<div class="flex items-center">
|
||||
<h2 class="font-semibold text-ink">{{ __('dashboard.activity') }}</h2>
|
||||
<span class="ml-auto rounded-pill bg-surface-2 px-2.5 py-1 text-xs font-semibold text-muted">{{ __('dashboard.activity_live') }}</span>
|
||||
</div>
|
||||
<ul class="mt-2 divide-y divide-line">
|
||||
@foreach ($activity as $a)
|
||||
<li class="flex items-start gap-3 py-2.5 text-sm">
|
||||
<span class="grid size-7 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-active"><x-ui.icon :name="$a['icon']" class="size-4" /></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-body">{{ $a['text'] }}</p>
|
||||
<p class="font-mono text-xs text-faint">{{ $a['time'] }}</p>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export default {
|
|||
text: 'var(--accent-text)',
|
||||
},
|
||||
'on-accent': 'var(--on-accent)',
|
||||
success: { DEFAULT: 'var(--success)', bg: 'var(--success-bg)', border: 'var(--success-border)' },
|
||||
success: { DEFAULT: 'var(--success)', bg: 'var(--success-bg)', border: 'var(--success-border)', bright: 'var(--success-bright)' },
|
||||
warning: { DEFAULT: 'var(--warning)', bg: 'var(--warning-bg)', border: 'var(--warning-border)' },
|
||||
danger: { DEFAULT: 'var(--danger)', bg: 'var(--danger-bg)', border: 'var(--danger-border)' },
|
||||
info: { DEFAULT: 'var(--info)', bg: 'var(--info-bg)', border: 'var(--info-border)' },
|
||||
|
|
@ -48,12 +48,21 @@ export default {
|
|||
lg: 'var(--text-lg)', xl: 'var(--text-xl)', '2xl': 'var(--text-2xl)', '3xl': 'var(--text-3xl)',
|
||||
},
|
||||
borderRadius: {
|
||||
sm: 'var(--radius-sm)', DEFAULT: 'var(--radius)', lg: 'var(--radius-lg)', pill: 'var(--radius-pill)',
|
||||
sm: 'var(--radius-sm)', DEFAULT: 'var(--radius)', lg: 'var(--radius-lg)', xl: 'var(--radius-xl)', pill: 'var(--radius-pill)',
|
||||
},
|
||||
boxShadow: {
|
||||
xs: 'var(--shadow-xs)', sm: 'var(--shadow-sm)', md: 'var(--shadow-md)',
|
||||
},
|
||||
ringColor: { accent: 'var(--accent-ring)' },
|
||||
keyframes: {
|
||||
rise: {
|
||||
'0%': { opacity: '0', transform: 'translateY(16px)' },
|
||||
'100%': { opacity: '1', transform: 'none' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
rise: 'rise 0.6s cubic-bezier(.2,.7,.2,1) both',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
|
|
|||
Loading…
Reference in New Issue