diff --git a/app/Livewire/Onboarding/Tour.php b/app/Livewire/Onboarding/Tour.php new file mode 100644 index 0000000..c0fe49c --- /dev/null +++ b/app/Livewire/Onboarding/Tour.php @@ -0,0 +1,55 @@ +autostart = Auth::user()?->onboarding_tour_completed_at === null; + } + + /** Persist that the tour has been finished or skipped, so it won't auto-open again. */ + public function markSeen(): void + { + $user = Auth::user(); + if ($user !== null && $user->onboarding_tour_completed_at === null) { + $user->forceFill(['onboarding_tour_completed_at' => now()])->save(); + } + } + + /** + * The tour steps. A null `target` is a centred card (welcome / finish); a non-null `target` + * matches a `data-tour="…"` attribute on a sidebar nav item and is spotlighted. + * + * @return array> + */ + public function steps(): array + { + return [ + ['target' => null, 'icon' => 'command', 'eyebrow' => __('onboarding.welcome_eyebrow'), 'title' => __('onboarding.welcome_title'), 'body' => __('onboarding.welcome_body')], + ['target' => 'dashboard', 'icon' => 'dashboard', 'eyebrow' => __('onboarding.dashboard_eyebrow'), 'title' => __('onboarding.dashboard_title'), 'body' => __('onboarding.dashboard_body')], + ['target' => 'servers', 'icon' => 'server', 'eyebrow' => __('onboarding.servers_eyebrow'), 'title' => __('onboarding.servers_title'), 'body' => __('onboarding.servers_body')], + ['target' => 'terminal', 'icon' => 'terminal', 'eyebrow' => __('onboarding.terminal_eyebrow'), 'title' => __('onboarding.terminal_title'), 'body' => __('onboarding.terminal_body')], + ['target' => 'settings', 'icon' => 'settings', 'eyebrow' => __('onboarding.settings_eyebrow'), 'title' => __('onboarding.settings_title'), 'body' => __('onboarding.settings_body')], + ['target' => null, 'icon' => 'shield', 'eyebrow' => __('onboarding.done_eyebrow'), 'title' => __('onboarding.done_title'), 'body' => __('onboarding.done_body')], + ]; + } + + public function render() + { + return view('livewire.onboarding.tour', ['steps' => $this->steps()]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index be79caf..a11aa7f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -31,6 +31,7 @@ class User extends Authenticatable 'two_factor_recovery_codes' => 'encrypted:array', 'two_factor_confirmed_at' => 'datetime', 'must_change_password' => 'boolean', + 'onboarding_tour_completed_at' => 'datetime', ]; } diff --git a/database/migrations/2026_06_26_000001_add_onboarding_tour_to_users.php b/database/migrations/2026_06_26_000001_add_onboarding_tour_to_users.php new file mode 100644 index 0000000..148465e --- /dev/null +++ b/database/migrations/2026_06_26_000001_add_onboarding_tour_to_users.php @@ -0,0 +1,27 @@ +timestamp('onboarding_tour_completed_at')->nullable()->after('must_change_password'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('onboarding_tour_completed_at'); + }); + } +}; diff --git a/lang/de/onboarding.php b/lang/de/onboarding.php new file mode 100644 index 0000000..9387158 --- /dev/null +++ b/lang/de/onboarding.php @@ -0,0 +1,36 @@ + 'Einführungs-Tour', + + 'welcome_eyebrow' => 'Willkommen', + 'welcome_title' => 'Willkommen bei Clusev', + 'welcome_body' => 'Eine Konsole für deine gesamte Server-Flotte — agentenlos über SSH. Diese kurze Tour zeigt dir die wichtigsten Bereiche. Du kannst sie jederzeit überspringen.', + + 'dashboard_eyebrow' => 'Übersicht', + 'dashboard_title' => 'Dashboard', + 'dashboard_body' => 'Live-Metriken deiner Flotte auf einen Blick — CPU, RAM und der Status jedes Servers in Echtzeit.', + + 'servers_eyebrow' => 'Flotte', + 'servers_title' => 'Server', + 'servers_body' => 'Server hinzufügen und verwalten. Der Zugang läuft agentenlos über SSH; die Zugangsdaten liegen verschlüsselt im Vault.', + + 'terminal_eyebrow' => 'Betrieb', + 'terminal_title' => 'Terminal', + 'terminal_body' => 'Ein vollwertiges SSH-Terminal pro Server — und für den Clusev-Host selbst. Mit Host-Key-Pinning gegen MITM.', + + 'settings_eyebrow' => 'Konto', + 'settings_title' => 'Einstellungen', + 'settings_body' => 'Profil, 2FA, Konten und Sicherheit. Diese Tour kannst du hier jederzeit erneut starten.', + + 'done_eyebrow' => 'Fertig', + 'done_title' => 'Alles bereit', + 'done_body' => 'Das war die Kurztour. Leg los — und starte sie bei Bedarf jederzeit in den Einstellungen neu.', + + 'skip' => 'Überspringen', + 'back' => 'Zurück', + 'next' => 'Weiter', + 'done' => 'Fertig', + 'relaunch' => 'Tour starten', +]; diff --git a/lang/en/onboarding.php b/lang/en/onboarding.php new file mode 100644 index 0000000..ded4768 --- /dev/null +++ b/lang/en/onboarding.php @@ -0,0 +1,36 @@ + 'Onboarding tour', + + 'welcome_eyebrow' => 'Welcome', + 'welcome_title' => 'Welcome to Clusev', + 'welcome_body' => 'One console for your entire server fleet — agentless over SSH. This short tour points out the key areas. You can skip it any time.', + + 'dashboard_eyebrow' => 'Overview', + 'dashboard_title' => 'Dashboard', + 'dashboard_body' => 'Your fleet\'s live metrics at a glance — CPU, RAM and each server\'s status in real time.', + + 'servers_eyebrow' => 'Fleet', + 'servers_title' => 'Servers', + 'servers_body' => 'Add and manage servers. Access is agentless over SSH; credentials are held encrypted in the vault.', + + 'terminal_eyebrow' => 'Operations', + 'terminal_title' => 'Terminal', + 'terminal_body' => 'A full SSH terminal per server — and for the Clusev host itself. With host-key pinning against MITM.', + + 'settings_eyebrow' => 'Account', + 'settings_title' => 'Settings', + 'settings_body' => 'Profile, 2FA, accounts and security. You can relaunch this tour from here any time.', + + 'done_eyebrow' => 'Done', + 'done_title' => 'You\'re all set', + 'done_body' => 'That\'s the quick tour. Dive in — and relaunch it any time from Settings if you need it.', + + 'skip' => 'Skip', + 'back' => 'Back', + 'next' => 'Next', + 'done' => 'Done', + 'relaunch' => 'Start tour', +]; diff --git a/resources/js/app.js b/resources/js/app.js index 7c81f77..91af45f 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -145,6 +145,71 @@ document.addEventListener('alpine:init', () => { window.LivewireUIModal = guarded; } + // ── Onboarding tour ───────────────────────────────────────────────── + // First-run spotlight tour. `steps` come from the Livewire component; a step with a `target` + // spotlights the sidebar nav item carrying data-tour="", otherwise the card is centred. + // Auto-opens once (autostart), relaunchable via the `onboarding:start` window event; finishing + // or skipping calls $wire.markSeen() so it never auto-opens again. + window.Alpine.data('onboardingTour', (steps = [], autostart = false) => ({ + steps, + open: false, + i: 0, + rect: null, // spotlight rect (fixed-viewport coords) or null → centred card + get step() { return this.steps[this.i] || {}; }, + get isLast() { return this.i === this.steps.length - 1; }, + + init() { + this._start = () => this.launch(); + this._reflow = () => { if (this.open) this.place(); }; + window.addEventListener('onboarding:start', this._start); + window.addEventListener('resize', this._reflow); + window.addEventListener('scroll', this._reflow, true); + // Auto-open once — but not if it was already dismissed this session (guards the window + // between finish() and markSeen() persisting, e.g. a fast reload). @persist covers navigation. + if (autostart && !this._dismissed()) this.$nextTick(() => this.launch()); + }, + _dismissed() { try { return sessionStorage.getItem('clusev.tour.dismissed') === '1'; } catch (_) { return false; } }, + destroy() { + window.removeEventListener('onboarding:start', this._start); + window.removeEventListener('resize', this._reflow); + window.removeEventListener('scroll', this._reflow, true); + }, + + launch() { this.i = 0; this.open = true; this.$nextTick(() => this.place()); }, + place() { + const t = this.step.target; + if (!t) { this.rect = null; return; } + const el = document.querySelector(`[data-tour="${t}"]`); + const r = el && el.getBoundingClientRect(); + // Hidden target (e.g. the mobile drawer is closed) → fall back to a centred card. + this.rect = (r && r.width > 0 && r.height > 0) ? { top: r.top, left: r.left, width: r.width, height: r.height } : null; + }, + next() { if (this.isLast) return this.finish(); this.i++; this.$nextTick(() => this.place()); }, + prev() { if (this.i > 0) { this.i--; this.$nextTick(() => this.place()); } }, + skip() { this.finish(); }, + finish() { + this.open = false; + this.rect = null; + try { sessionStorage.setItem('clusev.tour.dismissed', '1'); } catch (_) {} + try { this.$wire.markSeen(); } catch (_) {} + }, + + spotStyle() { + const r = this.rect; if (!r) return 'display:none'; + const p = 6; + return `top:${r.top - p}px;left:${r.left - p}px;width:${r.width + p * 2}px;height:${r.height + p * 2}px`; + }, + cardStyle() { + const r = this.rect; if (!r) return ''; + const gap = 18, w = 380, h = 250; + // Prefer to the right of the target (the sidebar sits on the left); flip left if it overflows. + let left = r.left + r.width + gap; + if (left + w > window.innerWidth - 12) left = Math.max(12, r.left - w - gap); + const top = Math.max(12, Math.min(r.top, window.innerHeight - h - 12)); + return `top:${top}px;left:${left}px`; + }, + })); + // ── Modal trigger ─────────────────────────────────────────────────── // Reused by . Gives EVERY modal-opening control an immediate // spinner (pending flips synchronously on click — no round-trip needed) that clears diff --git a/resources/views/components/nav-item.blade.php b/resources/views/components/nav-item.blade.php index 8026ab2..d548575 100644 --- a/resources/views/components/nav-item.blade.php +++ b/resources/views/components/nav-item.blade.php @@ -1,10 +1,9 @@ @props(['icon', 'href' => '#', 'active' => false, 'badge' => null]) -class([ 'group flex min-h-11 items-center gap-3 rounded-md border px-3 py-2 text-sm transition-colors', 'border-accent/25 bg-accent/10 text-ink shadow-[inset_2px_0_0_var(--color-accent)]' => $active, 'border-transparent text-ink-2 hover:bg-raised hover:text-ink' => ! $active, - ]) + ])->merge(['href' => $href]) }} @if ($active) aria-current="page" @endif> $active]) /> {{ $slot }} diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index 49acee9..674957a 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -28,16 +28,16 @@ {{-- Nav --}}