feat(BUG-2): redesign sidebar from template — Geist font, section nav, CSS-driven collapse

Implement user-provided template design:
- Split nav into Workspace / Account sections with labels
- New CSS design tokens (--nim-*) for sidebar without breaking app vars
- Active indicator: left glow bar via ::before, CSS class nim-nav-active
- Collapsed tooltip via CSS ::after content: attr(data-label)
- Geist font for sidebar via Google Fonts
- 264px expanded / 72px collapsed sidebar widths
- All collapse state driven by html[data-sidebar-collapsed] critical CSS
  (no Alpine reactivity needed, works across @persist boundary)
- Mobile: always full expanded sidebar regardless of collapse state
- Icon partial at partials/sidebar-icon with 24px viewBox icons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-17 13:40:16 +02:00
parent b241df93a8
commit d2d43e1097
3 changed files with 341 additions and 316 deletions

View File

@ -1,187 +1,140 @@
@php
$workspace = current_workspace();
$workspaces = auth()->check() ? auth()->user()->workspaces()->orderBy('name')->get() : collect();
$wsUlid = $workspace?->ulid;
$nav = $workspace ? [
[
'label' => 'Dashboard',
'route' => 'w.dashboard',
'params' => [$wsUlid],
'match' => 'w.dashboard',
'icon' => 'dashboard',
],
[
'label' => 'Links',
'route' => 'w.links.index',
'params' => [$wsUlid],
'match' => 'w.links.*',
'icon' => 'links',
],
[
'label' => 'QR Codes',
'route' => 'w.qr.index',
'params' => [$wsUlid],
'match' => 'w.qr.*',
'icon' => 'qr',
],
[
'label' => 'Link-in-Bio',
'route' => 'w.bio.index',
'params' => [$wsUlid],
'match' => 'w.bio.*',
'icon' => 'bio',
],
[
'label' => 'Analytics',
'route' => 'w.analytics.index',
'params' => [$wsUlid],
'match' => 'w.analytics.*',
'icon' => 'analytics',
],
[
'label' => 'Domains',
'route' => 'w.domains.index',
'params' => [$wsUlid],
'match' => 'w.domains.*',
'icon' => 'domains',
],
[
'label' => 'Settings',
'route' => 'w.settings.index',
'params' => [$wsUlid],
'match' => 'w.settings.*',
'icon' => 'settings',
],
[
'label' => 'Billing',
'route' => 'w.billing.index',
'params' => [$wsUlid],
'match' => 'w.billing.*',
'icon' => 'billing',
],
[
'label' => 'Team',
'route' => 'w.team.index',
'params' => [$wsUlid],
'match' => 'w.team.*',
'icon' => 'team',
],
[
'label' => 'AI',
'route' => 'w.ai.insights',
'params' => [$wsUlid],
'match' => 'w.ai.*',
'icon' => 'ai',
],
$wsNav = $workspace ? [
['label' => 'Dashboard', 'route' => 'w.dashboard', 'params' => [$wsUlid], 'match' => 'w.dashboard', 'icon' => 'dashboard', 'exact' => true],
['label' => 'Links', 'route' => 'w.links.index', 'params' => [$wsUlid], 'match' => 'w.links.*', 'icon' => 'links'],
['label' => 'QR Codes', 'route' => 'w.qr.index', 'params' => [$wsUlid], 'match' => 'w.qr.*', 'icon' => 'qr'],
['label' => 'Link-in-Bio','route' => 'w.bio.index', 'params' => [$wsUlid], 'match' => 'w.bio.*', 'icon' => 'bio'],
['label' => 'Analytics', 'route' => 'w.analytics.index','params' => [$wsUlid], 'match' => 'w.analytics.*', 'icon' => 'analytics'],
['label' => 'Domains', 'route' => 'w.domains.index', 'params' => [$wsUlid], 'match' => 'w.domains.*', 'icon' => 'domains'],
] : [
[
'label' => 'Dashboard',
'route' => 'dashboard',
'params' => [],
'match' => 'dashboard',
'icon' => 'dashboard',
],
['label' => 'Dashboard', 'route' => 'dashboard', 'params' => [], 'match' => 'dashboard', 'icon' => 'dashboard', 'exact' => true],
];
// Plan usage
$accountNav = $workspace ? [
['label' => 'Settings', 'route' => 'w.settings.index', 'params' => [$wsUlid], 'match' => 'w.settings.*', 'icon' => 'settings'],
['label' => 'Billing', 'route' => 'w.billing.index', 'params' => [$wsUlid], 'match' => 'w.billing.*', 'icon' => 'billing'],
['label' => 'Team', 'route' => 'w.team.index', 'params' => [$wsUlid], 'match' => 'w.team.*', 'icon' => 'team'],
['label' => 'AI', 'route' => 'w.ai.insights', 'params' => [$wsUlid], 'match' => 'w.ai.*', 'icon' => 'ai'],
] : [];
$plan = $workspace?->plan;
$linksUsed = $workspace ? $workspace->links()->count() : 0;
$linkLimit = $plan?->link_limit ?? 50;
$usagePct = $linkLimit > 0 ? min(100, round(($linksUsed / $linkLimit) * 100)) : 0;
// User initials
$userName = auth()->user()?->name ?? '';
$initials = collect(explode(' ', trim($userName)))->map(fn($w) => strtoupper(substr($w, 0, 1)))->take(2)->implode('');
@endphp
<aside
x-data="{ wsOpen: false }"
class="sidebar-aside fixed inset-y-0 left-0 h-screen flex flex-col bg-s1 border-r border-white/[.06] z-40 -translate-x-full md:translate-x-0 transition-[width,transform] duration-200 ease-in-out"
:style="$store.sidebar?.mobileOpen ? 'translate: 0' : null"
class="nim-sidebar fixed inset-y-0 left-0 h-screen flex flex-col z-40 -translate-x-full md:translate-x-0"
style="transition: width .28s cubic-bezier(.4,0,.2,1), transform .28s cubic-bezier(.4,0,.2,1);"
:style="$store.sidebar?.mobileOpen ? 'transform: translateX(0)' : null"
>
<!-- Header: Logo + Workspace Switcher -->
<div class="flex-shrink-0 relative" @click.outside="wsOpen = false">
<!-- Collapsed state: centered logo acts as expand toggle -->
<div class="nim-sb-collapsed flex items-center justify-center py-4 border-b border-white/[.06]">
<button @click="$store.sidebar.toggle()" title="Expand sidebar" class="flex items-center justify-center w-7 h-7 hover:opacity-75 transition-opacity">
<img src="/brand/logo-mark.svg" alt="Nimuli" class="w-7 h-7">
</button>
</div>
<!-- Expanded state: full header with workspace switcher -->
<div class="nim-sb-expanded">
<div class="flex items-stretch border-b border-white/[.06]">
{{-- ═══ Header ═══ --}}
<header class="nim-sb-header flex-shrink-0 px-3.5 py-3 border-b" style="border-color:var(--nim-border); background:linear-gradient(180deg,rgba(255,255,255,0.015),transparent);" @click.outside="wsOpen = false">
<button
@click="wsOpen = !wsOpen"
class="flex-1 flex items-center gap-3 px-4 py-4 hover:bg-s2 transition-colors min-w-0"
:aria-expanded="wsOpen.toString()"
class="ws-trigger w-full flex items-center gap-3 px-2.5 py-2 rounded-[10px] transition-colors duration-150"
style="hover:background:var(--nim-surface-3)"
onmouseenter="this.style.background='var(--nim-surface-3)'"
onmouseleave="this.style.background=''"
>
<img src="/brand/logo-mark.svg" alt="Nimuli" class="w-7 h-7 flex-shrink-0">
<div class="flex-1 text-left min-w-0">
<div class="text-sm font-bold tracking-tight text-accent-gradient">nimuli</div>
@if($workspace)
<div class="text-xs text-t2 truncate mt-0.5">{{ $workspace->name }}</div>
@else
<div class="text-xs text-t2">Select workspace</div>
@endif
</div>
<svg class="w-4 h-4 text-t3 flex-shrink-0 transition-transform" :class="wsOpen && 'rotate-180'" fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6l4 4 4-4"/>
<span class="ws-logo flex-shrink-0 w-9 h-9 rounded-[9px] flex items-center justify-center text-white font-bold text-[18px] relative overflow-hidden"
style="background:var(--nim-brand-grad);box-shadow:0 6px 16px -6px rgba(168,85,247,.55),inset 0 1px 0 rgba(255,255,255,.25)">
<span style="position:relative;z-index:1">N</span>
<span style="position:absolute;inset:0;background:radial-gradient(circle at 30% 20%,rgba(255,255,255,.35),transparent 50%);pointer-events:none"></span>
</span>
<span class="ws-text flex-1 min-w-0 text-left" style="display:grid;line-height:1.2">
<span class="ws-brand font-bold text-[15px] tracking-tight"
style="background:var(--nim-brand-grad);-webkit-background-clip:text;background-clip:text;color:transparent">nimuli</span>
<span class="ws-name text-[12.5px] truncate" style="color:var(--nim-text-muted)">
{{ $workspace?->name ?? 'Select workspace' }}
</span>
</span>
<svg class="ws-chevron flex-shrink-0 w-4 h-4 transition-transform duration-200" :class="wsOpen && 'rotate-180'"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<!-- Mobile-only close button -->
<button @click="$store.sidebar.closeMobile()" class="md:hidden flex items-center px-3 text-t3 hover:text-t1 border-l border-white/[.06] transition-colors" title="Close sidebar">
{{-- Mobile close --}}
<button @click="$store.sidebar.closeMobile()"
class="md:hidden absolute right-3 top-4 p-2 rounded-lg transition-colors"
style="color:var(--nim-text-muted)"
onmouseenter="this.style.background='rgba(255,255,255,0.05)'"
onmouseleave="this.style.background=''">
<svg class="w-5 h-5" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6l8 8M6 14L14 6"/>
</svg>
</button>
</div>
<!-- Workspace Switcher Dropdown -->
{{-- Workspace dropdown --}}
<div
x-show="wsOpen"
x-transition:enter="transition ease-out duration-100"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-75"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-1"
class="bg-s2 border-b border-white/[.06] py-1"
class="mt-2 rounded-[10px] overflow-hidden border"
style="background:var(--nim-surface-2);border-color:var(--nim-border)"
x-cloak
>
@forelse($workspaces as $ws)
<a
href="{{ route('w.links.index', $ws->ulid) }}"
wire:navigate
class="flex items-center gap-2 px-4 py-2 text-sm hover:bg-s3 transition-colors {{ $workspace?->id === $ws->id ? 'text-t1' : 'text-t2' }}"
@click="wsOpen = false"
class="flex items-center gap-2.5 px-3.5 py-3 text-[13.5px] transition-colors border-b"
style="border-color:var(--nim-border);{{ $workspace?->id === $ws->id ? 'color:var(--nim-text);font-weight:500' : 'color:var(--nim-text-muted)' }}"
onmouseenter="this.style.background='rgba(255,255,255,0.03)'"
onmouseleave="this.style.background=''"
>
<span class="w-[22px] h-[22px] rounded-[6px] flex items-center justify-center text-white font-bold text-[11px] flex-shrink-0"
style="background:var(--nim-brand-grad)">{{ strtoupper(substr($ws->name, 0, 1)) }}</span>
<span class="flex-1 truncate">{{ $ws->name }}</span>
@if($workspace?->id === $ws->id)
<svg class="w-3.5 h-3.5 text-blue flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
<svg class="w-4 h-4 flex-shrink-0" style="color:var(--nim-accent-2)"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
@endif
</a>
@empty
<div class="px-4 py-2 text-xs text-t3">No workspaces yet</div>
<div class="px-3.5 py-3 text-xs" style="color:var(--nim-text-dim)">No workspaces yet</div>
@endforelse
<div class="border-t border-white/[.06] mt-1 pt-1">
<button
@click="$dispatch('openModal', {component: 'modals.create-workspace'})"
class="flex items-center gap-2 px-4 py-2 text-sm text-t2 hover:text-t1 hover:bg-s3 transition-colors w-full text-left">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 3v10M3 8h10"/>
@click="$dispatch('openModal', {component: 'modals.create-workspace'}); wsOpen = false"
class="w-full flex items-center gap-2.5 px-3.5 py-3 text-[13.5px] transition-colors"
style="color:var(--nim-text-muted)"
onmouseenter="this.style.background='rgba(255,255,255,0.03)';this.style.color='var(--nim-text)'"
onmouseleave="this.style.background='';this.style.color='var(--nim-text-muted)'"
>
<svg class="w-[18px] h-[18px] flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
New Workspace
<span>New Workspace</span>
</button>
</div>
</div>
</div>
</div>
</header>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-0.5">
@foreach($nav as $item)
{{-- ═══ Nav ═══ --}}
<nav class="nim-sb-nav flex-1 overflow-y-auto overflow-x-hidden px-2.5 py-2.5" aria-label="Main">
@if($workspace)
<div class="nav-label px-3 pt-2 pb-1.5 text-[11px] uppercase tracking-[.08em] font-medium" style="color:var(--nim-text-dim)">Workspace</div>
@endif
@foreach($wsNav as $item)
@php
$isActive = request()->routeIs($item['match']);
$url = Route::has($item['route']) ? route($item['route'], $item['params']) : '#';
@ -190,121 +143,110 @@
href="{{ $url }}"
wire:navigate
data-nav-link="{{ $item['route'] }}"
@if(in_array($item['route'], ['w.dashboard', 'dashboard'])) data-nav-exact="1" @endif
:title="$store.sidebar.isCollapsed ? '{{ $item['label'] }}' : ''"
class="sidebar-nav-link flex items-center py-2 rounded-lg text-sm transition-colors group
{{ $isActive
? 'bg-blue/10 text-blue'
: 'text-t2 hover:text-t1 hover:bg-s2' }}"
@isset($item['exact']) data-nav-exact="1" @endisset
data-label="{{ $item['label'] }}"
class="nav-item relative flex items-center gap-3 px-3 py-[9px] rounded-lg text-sm font-medium w-full {{ $isActive ? 'nim-nav-active' : '' }}"
>
<span class="w-5 h-5 flex-shrink-0 {{ $isActive ? 'text-blue' : 'text-t3 group-hover:text-t2' }}">
@if($item['icon'] === 'dashboard')
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2 2h5v5H2zM9 2h5v5H9zM2 9h5v5H2zM9 9h5v5H9z"/>
</svg>
@elseif($item['icon'] === 'links')
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.5 6.5l-3 3M6.75 4.5A2.25 2.25 0 014.5 6.75v0A2.25 2.25 0 016.75 9H8M9.25 7H10.5a2.25 2.25 0 010 4.5v0A2.25 2.25 0 018.25 9.25"/>
</svg>
@elseif($item['icon'] === 'qr')
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2 2h4v4H2zM10 2h4v4h-4zM2 10h4v4H2zM10 10h1M13 10h1M10 13h1M13 13h1M11 11h2v2h-2z"/>
</svg>
@elseif($item['icon'] === 'bio')
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4h10M3 8h10M3 12h6"/>
</svg>
@elseif($item['icon'] === 'analytics')
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2 13l3-4 3 2 3-5 3 3"/>
</svg>
@elseif($item['icon'] === 'domains')
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<circle cx="8" cy="8" r="6"/><path stroke-linecap="round" stroke-linejoin="round" d="M2 8h12M8 2c-1.5 2-2.5 3.7-2.5 6S6.5 12 8 14M8 2c1.5 2 2.5 3.7 2.5 6S9.5 12 8 14"/>
</svg>
@elseif($item['icon'] === 'settings')
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 10a2 2 0 100-4 2 2 0 000 4z"/><path stroke-linecap="round" stroke-linejoin="round" d="M13.3 7a5.5 5.5 0 00-.3-1.1l1.4-1.4-1.9-1.9-1.4 1.4A5.5 5.5 0 009 3.3V1.5h-2v1.8a5.5 5.5 0 00-1.1.3L4.5 2.2 2.6 4.1 4 5.5A5.5 5.5 0 003.3 7H1.5v2h1.8a5.5 5.5 0 00.3 1.1L2.2 11.5l1.9 1.9 1.4-1.4c.35.12.71.22 1.1.3v1.8h2v-1.8a5.5 5.5 0 001.1-.3l1.4 1.4 1.9-1.9-1.4-1.4c.12-.35.22-.71.3-1.1h1.8V7h-1.8z"/>
</svg>
@elseif($item['icon'] === 'billing')
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<rect x="1.5" y="3.5" width="13" height="9" rx="1.5"/><path stroke-linecap="round" d="M1.5 6.5h13"/>
</svg>
@elseif($item['icon'] === 'team')
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<circle cx="6" cy="5" r="2"/><path stroke-linecap="round" stroke-linejoin="round" d="M1.5 14c0-2.5 2-4 4.5-4s4.5 1.5 4.5 4"/><circle cx="11.5" cy="5" r="1.5"/><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 13c0-1.8-1-3-2.5-3.5"/>
</svg>
@elseif($item['icon'] === 'ai')
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 1.5l1.5 3 3.5.5-2.5 2.5.5 3.5L8 9.5 5 11l.5-3.5L3 5l3.5-.5L8 1.5z"/>
</svg>
@endif
<span class="w-[18px] h-[18px] flex-shrink-0">
@include('partials.sidebar-icon', ['icon' => $item['icon']])
</span>
<span class="nim-sb-expanded truncate">{{ $item['label'] }}</span>
<span class="nav-text flex-1 truncate">{{ $item['label'] }}</span>
</a>
@endforeach
@if($workspace && count($accountNav))
<div class="nav-label px-3 pt-4 pb-1.5 text-[11px] uppercase tracking-[.08em] font-medium" style="color:var(--nim-text-dim)">Account</div>
@endif
@foreach($accountNav as $item)
@php
$isActive = request()->routeIs($item['match']);
$url = Route::has($item['route']) ? route($item['route'], $item['params']) : '#';
@endphp
<a
href="{{ $url }}"
wire:navigate
data-nav-link="{{ $item['route'] }}"
data-label="{{ $item['label'] }}"
class="nav-item relative flex items-center gap-3 px-3 py-[9px] rounded-lg text-sm font-medium w-full {{ $isActive ? 'nim-nav-active' : '' }}"
>
<span class="w-[18px] h-[18px] flex-shrink-0">
@include('partials.sidebar-icon', ['icon' => $item['icon']])
</span>
<span class="nav-text flex-1 truncate">{{ $item['label'] }}</span>
@if($item['icon'] === 'ai')
<span class="nav-badge text-[10.5px] px-1.5 py-0.5 rounded-full font-semibold tracking-wide"
style="background:var(--nim-accent-bg-strong);color:var(--nim-accent-2)">New</span>
@endif
</a>
@endforeach
{{-- Plan usage --}}
@if($workspace && $plan)
<div class="nav-plan-usage px-1 pt-4 pb-1">
<div class="p-3 rounded-xl border" style="background:var(--nim-surface-2);border-color:var(--nim-border)">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium" style="color:var(--nim-text-muted)">{{ ucfirst($plan->name) }} Plan</span>
@if(str_contains(strtolower($plan->name), 'free'))
<a href="{{ Route::has('w.billing.index') ? route('w.billing.index', $wsUlid) : '#' }}"
wire:navigate class="text-xs hover:underline" style="color:var(--nim-accent-2)">Upgrade</a>
@endif
</div>
<div class="flex justify-between mb-1.5">
<span class="text-xs" style="color:var(--nim-text-dim)">Links</span>
<span class="text-xs" style="color:var(--nim-text-muted)">{{ number_format($linksUsed) }} / {{ number_format($linkLimit) }}</span>
</div>
<div class="w-full h-1 rounded-full" style="background:var(--nim-surface-3)">
<div class="h-1 rounded-full transition-all" style="width:{{ $usagePct }}%;background:var(--nim-accent)"></div>
</div>
</div>
</div>
@endif
</nav>
<!-- Collapse Toggle (desktop only) -->
<div class="max-md:hidden flex-shrink-0 border-t border-white/[.06] px-2 py-2">
{{-- ═══ Footer ═══ --}}
<footer class="nim-sb-footer flex-shrink-0 border-t" style="border-color:var(--nim-border);background:linear-gradient(0deg,rgba(255,255,255,.012),transparent)">
{{-- Collapse toggle (desktop only) --}}
<button
@click="$store.sidebar.toggle()"
:title="$store.sidebar.isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'"
class="sidebar-nav-link flex items-center w-full py-2 rounded-lg text-t3 hover:text-t1 hover:bg-s2 transition-colors"
class="collapse-row max-md:hidden w-full flex items-center gap-2.5 px-[18px] py-2.5 text-[13px] border-b transition-colors duration-150"
style="color:var(--nim-text-dim);border-color:var(--nim-border)"
onmouseenter="this.style.color='var(--nim-text)'"
onmouseleave="this.style.color='var(--nim-text-dim)'"
>
<span class="w-4 h-4 flex-shrink-0">
<svg class="nim-sb-expanded" fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 3L5 8l5 5"/>
<svg class="w-4 h-4 flex-shrink-0 transition-transform duration-250" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
<svg class="nim-sb-collapsed" fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 3l5 5-5 5"/>
</svg>
</span>
<span class="nim-sb-expanded text-xs">Collapse</span>
<span class="collapse-text">Collapse</span>
</button>
</div>
<!-- Plan Usage Box -->
@if($workspace && $plan)
<div class="nim-sb-expanded mx-3 mb-3 p-3 bg-s2 border border-white/[.06] rounded-lg">
<div class="flex items-center justify-between mb-1.5">
<span class="text-xs font-medium text-t2">{{ ucfirst($plan->name) }} Plan</span>
@if(str_contains(strtolower($plan->name), 'free'))
<a href="{{ Route::has('w.billing.index') ? route('w.billing.index', $wsUlid) : '#' }}" wire:navigate class="text-xs text-blue hover:underline">Upgrade</a>
@endif
</div>
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-t3">Links</span>
<span class="text-xs text-t2">{{ number_format($linksUsed) }} / {{ number_format($linkLimit) }}</span>
</div>
<div class="w-full bg-s3 rounded-full h-1">
<div class="bg-blue h-1 rounded-full transition-all" style="width: {{ $usagePct }}%"></div>
</div>
</div>
@endif
<!-- User Footer -->
{{-- User row --}}
@auth
<div class="flex-shrink-0 border-t border-white/[.06] p-3 flex items-center gap-3 min-w-0">
<div class="w-8 h-8 rounded-full bg-blue flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">
<div class="user-row flex items-center gap-3 px-3.5 py-3.5 min-w-0">
<div class="nim-avatar flex-shrink-0 w-9 h-9 rounded-full flex items-center justify-center text-white font-semibold text-[13px]"
style="background:linear-gradient(135deg,#8b5cf6,#6366f1);box-shadow:inset 0 1px 0 rgba(255,255,255,.18)">
{{ $initials ?: strtoupper(substr(auth()->user()->email, 0, 2)) }}
</div>
<div class="nim-sb-expanded flex-1 min-w-0">
<div class="text-xs font-medium text-t1 truncate">{{ auth()->user()->name }}</div>
<div class="text-xs text-t3 truncate">{{ auth()->user()->email }}</div>
<div class="user-info flex-1 min-w-0" style="line-height:1.2">
<div class="text-[13.5px] font-semibold truncate" style="color:var(--nim-text)">{{ auth()->user()->name }}</div>
<div class="text-[12px] truncate" style="color:var(--nim-text-dim)">{{ auth()->user()->email }}</div>
</div>
<form class="nim-sb-expanded" method="POST" action="{{ route('logout') }}">
<form class="user-logout-btn flex-shrink-0" method="POST" action="{{ route('logout') }}">
@csrf
<button
type="submit"
title="Logout"
class="text-t3 hover:text-t1 transition-colors"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 11.5l3-3.5-3-3.5M13.5 8H6M6 2.5H3a1 1 0 00-1 1v9a1 1 0 001 1h3"/>
<button type="submit" title="Sign out" aria-label="Sign out"
class="p-2 rounded-lg transition-colors duration-150"
style="color:var(--nim-text-dim)">
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</form>
</div>
@endauth
</footer>
</aside>

View File

@ -14,6 +14,9 @@
<link rel="manifest" href="/site.webmanifest">
<meta name="theme-color" content="#0a0a0f" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Sidebar-init: set collapsed attribute before first paint eliminates width flash -->
<script>
@ -42,9 +45,19 @@
<!-- Critical CSS: paint baseline before Vite/Tailwind loads -->
<style>
:root {
/* App tokens */
--c-bg: #0a0a0f; --c-s1: #111117; --c-s2: #16161e;
--c-b1: rgba(255,255,255,0.08); --c-accent: #7c3aed;
--c-t1: #f0f0f5; --c-t2: #b8b8d0;
/* Sidebar design tokens */
--nim-surface: #0e0e13; --nim-surface-2: #121218; --nim-surface-3: #16161e;
--nim-border: #1f1f29; --nim-border-strong: #2a2a36;
--nim-text: #e8e8ec; --nim-text-muted: #8a8a96; --nim-text-dim: #5b5b66;
--nim-accent: #8b5cf6; --nim-accent-2: #a78bfa;
--nim-accent-bg: rgba(139,92,246,.14); --nim-accent-bg-strong: rgba(139,92,246,.22);
--nim-accent-glow: rgba(139,92,246,.6);
--nim-brand-grad: linear-gradient(135deg,#a855f7 0%,#ec4899 100%);
--nim-sb-w: 264px; --nim-sb-c: 72px;
}
[data-theme="light"] {
--c-bg: #ffffff; --c-s1: #ffffff; --c-s2: #f9fafb;
@ -57,56 +70,105 @@
background: var(--c-bg); color: var(--c-t1);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
opacity: 0;
transition: opacity 120ms ease-in;
min-height: 100vh;
opacity: 0; transition: opacity 120ms ease-in; min-height: 100vh;
}
body.app-ready { opacity: 1; }
/* Skeleton sidebar & topbar */
/* Skeleton */
.skel-sidebar {
position: fixed; top: 0; left: 0; bottom: 0;
width: 240px; background: var(--c-s1);
border-right: 1px solid var(--c-b1); z-index: 1;
width: var(--nim-sb-w); background: var(--nim-surface);
border-right: 1px solid var(--nim-border); z-index: 1;
}
.skel-topbar {
position: fixed; top: 0; left: 240px; right: 0;
position: fixed; top: 0; left: var(--nim-sb-w); right: 0;
height: 57px; background: var(--c-s1);
border-bottom: 1px solid var(--c-b1); z-index: 1;
}
@media (max-width: 768px) {
.skel-sidebar { display: none; }
.skel-topbar { left: 0; }
}
@media (max-width: 768px) { .skel-sidebar { display:none; } .skel-topbar { left:0; } }
.app-spinner {
position: fixed; top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 28px; height: 28px;
border: 2.5px solid var(--c-b1);
border-top-color: var(--c-accent);
border-radius: 50%;
animation: nim-spin 0.55s linear infinite;
z-index: 10;
position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%);
width: 28px; height: 28px; border: 2.5px solid var(--c-b1);
border-top-color: var(--nim-accent); border-radius: 50%;
animation: nim-spin .55s linear infinite; z-index: 10;
}
html[data-sidebar-collapsed] .skel-sidebar { width: 64px; }
html[data-sidebar-collapsed] .skel-topbar { left: 64px; }
.sidebar-aside { width: 15rem; overflow-x: hidden; }
html[data-sidebar-collapsed] .skel-sidebar { width: var(--nim-sb-c); }
html[data-sidebar-collapsed] .skel-topbar { left: var(--nim-sb-c); }
body.app-ready .app-spinner, body.app-ready .skel-sidebar, body.app-ready .skel-topbar { display:none; }
@keyframes nim-spin { to { transform: translate(-50%,-50%) rotate(360deg); } }
/* Sidebar shell */
.nim-sidebar {
width: var(--nim-sb-w);
background: var(--nim-surface);
border-right: 1px solid var(--nim-border);
overflow-x: hidden;
font-family: 'Geist', 'Inter', ui-sans-serif, system-ui, sans-serif;
}
/* Content margin */
@media (min-width: 768px) {
.nim-content { margin-left: 210px; }
html[data-sidebar-collapsed] .nim-content { margin-left: 55px; }
html[data-sidebar-collapsed] .sidebar-aside { width: 4rem; }
.nim-content { margin-left: var(--nim-sb-w); }
html[data-sidebar-collapsed] .nim-content { margin-left: var(--nim-sb-c); }
html[data-sidebar-collapsed] .nim-sidebar { width: var(--nim-sb-c); }
}
/* Sidebar collapse visibility — desktop only; mobile always shows expanded state */
.nim-sb-collapsed { display: none !important; }
.sidebar-nav-link { gap: 0.75rem; padding-left: 0.75rem; padding-right: 0.75rem; }
/* Nav items */
.nav-item { color: var(--nim-text-muted); }
.nav-item:hover { background: var(--nim-surface-3) !important; color: var(--nim-text) !important; }
.nav-item.nim-nav-active {
background: var(--nim-accent-bg);
color: var(--nim-accent-2);
box-shadow: inset 0 0 0 1px rgba(139,92,246,.18);
}
.nav-item.nim-nav-active svg { color: var(--nim-accent-2); }
.nav-item.nim-nav-active::before {
content: ''; position: absolute;
left: -10px; top: 50%; transform: translateY(-50%);
width: 3px; height: 18px;
background: var(--nim-accent); border-radius: 0 3px 3px 0;
box-shadow: 0 0 12px var(--nim-accent-glow);
}
/* Nav scrollbar */
.nim-sb-nav { scrollbar-width: thin; scrollbar-color: var(--nim-border-strong) transparent; }
.nim-sb-nav::-webkit-scrollbar { width: 6px; }
.nim-sb-nav::-webkit-scrollbar-thumb { background: var(--nim-border-strong); border-radius: 3px; }
.nim-sb-nav::-webkit-scrollbar-track { background: transparent; }
/* Logout hover */
.user-logout-btn button:hover { color: #ef4444 !important; background: rgba(239,68,68,.08); border-radius: .5rem; }
/* Collapse state — desktop only */
@media (min-width: 768px) {
html[data-sidebar-collapsed] .nim-sb-expanded { display: none !important; }
html[data-sidebar-collapsed] .nim-sb-collapsed { display: flex !important; }
html[data-sidebar-collapsed] .sidebar-nav-link { justify-content: center; padding-left: 0.5rem; padding-right: 0.5rem; gap: 0; }
html[data-sidebar-collapsed] .ws-text,
html[data-sidebar-collapsed] .ws-chevron,
html[data-sidebar-collapsed] .nav-text,
html[data-sidebar-collapsed] .nav-label,
html[data-sidebar-collapsed] .nav-badge,
html[data-sidebar-collapsed] .nav-plan-usage,
html[data-sidebar-collapsed] .user-info,
html[data-sidebar-collapsed] .user-logout-btn,
html[data-sidebar-collapsed] .collapse-text { display: none !important; }
html[data-sidebar-collapsed] .ws-trigger { justify-content: center !important; padding: 8px !important; }
html[data-sidebar-collapsed] .nav-item { justify-content: center !important; padding: 10px !important; gap: 0 !important; }
html[data-sidebar-collapsed] .nav-item.nim-nav-active::before { display: none; }
html[data-sidebar-collapsed] .collapse-row { justify-content: center !important; padding: 12px !important; }
html[data-sidebar-collapsed] .collapse-row svg { transform: rotate(180deg); }
html[data-sidebar-collapsed] .user-row { justify-content: center !important; padding: 12px !important; gap: 0 !important; }
/* Tooltip on hover when collapsed */
html[data-sidebar-collapsed] .nav-item:hover::after {
content: attr(data-label);
position: absolute; left: calc(100% + 12px); top: 50%; transform: translateY(-50%);
background: var(--nim-surface-3); color: var(--nim-text);
padding: 6px 10px; border-radius: 6px; font-size: 12.5px;
white-space: nowrap; pointer-events: none;
border: 1px solid var(--nim-border-strong);
box-shadow: 0 8px 24px -6px rgba(0,0,0,.5); z-index: 100;
}
}
body.app-ready .app-spinner,
body.app-ready .skel-sidebar,
body.app-ready .skel-topbar { display: none; }
@keyframes nim-spin { to { transform: translate(-50%, -50%) rotate(360deg); } }
</style>
@vite(['resources/js/app.js'])
@ -118,7 +180,7 @@
<div class="skel-topbar" aria-hidden="true"></div>
<div class="app-spinner" aria-hidden="true"></div>
<div class="bg-bg text-t1 font-sans antialiased min-h-screen">
<div id="nim-app" class="bg-bg text-t1 font-sans antialiased min-h-screen">
<!-- Mobile Sidebar Overlay -->
<div
class="fixed inset-0 z-30 bg-black/60 md:hidden"
@ -140,10 +202,7 @@
@endpersist
<!-- Main content area: shifts right based on sidebar width -->
<div
class="nim-content flex flex-col min-h-screen transition-[margin-left] duration-200 ease-in-out"
:class="$store.sidebar?.isCollapsed ? 'md:ml-16' : 'md:ml-60'"
>
<div class="nim-content flex flex-col min-h-screen" style="transition: margin-left .28s cubic-bezier(.4,0,.2,1);">
@persist('topbar')
<x-topbar :title="$title ?? ''" />
@endpersist
@ -181,18 +240,20 @@
var path = window.location.pathname;
document.querySelectorAll('[data-nav-link]').forEach(function (el) {
var href = new URL(el.href, location.origin).pathname;
var isActive = href && (path === href || (! el.dataset.navExact && path.startsWith(href + '/')));
el.classList.toggle('bg-blue/10', isActive);
el.classList.toggle('text-blue', isActive);
el.classList.toggle('text-t2', !isActive);
el.classList.toggle('hover:text-t1', !isActive);
el.classList.toggle('hover:bg-s2', !isActive);
// Also update icon span — sidebar is persisted so server classes stay stale
var icon = el.querySelector('span.flex-shrink-0');
if (icon) {
icon.classList.toggle('text-blue', isActive);
icon.classList.toggle('text-t3', !isActive);
var isActive = href && (path === href || (!el.dataset.navExact && path.startsWith(href + '/')));
el.classList.toggle('nim-nav-active', isActive);
if (isActive) {
el.style.background = 'var(--nim-accent-bg)';
el.style.color = 'var(--nim-accent-2)';
el.style.boxShadow = 'inset 0 0 0 1px rgba(139,92,246,.18)';
} else {
el.style.background = '';
el.style.color = '';
el.style.boxShadow = '';
}
// Update icon SVG colour
var icon = el.querySelector('span.flex-shrink-0 svg');
if (icon) icon.style.color = isActive ? 'var(--nim-accent-2)' : '';
});
}
document.addEventListener('DOMContentLoaded', updateActiveNav);

View File

@ -0,0 +1,22 @@
@php $i = $icon ?? ''; @endphp
@if($i === 'dashboard')
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:100%;height:100%"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
@elseif($i === 'links')
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:100%;height:100%"><path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07L11.59 4.34"/><path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07L12.41 19.66"/></svg>
@elseif($i === 'qr')
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:100%;height:100%"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><line x1="14" y1="14" x2="17" y2="14"/><line x1="20" y1="14" x2="21" y2="14"/><line x1="14" y1="17" x2="14" y2="20"/><line x1="17" y1="17" x2="21" y2="17"/><line x1="17" y1="20" x2="17" y2="21"/><line x1="20" y1="20" x2="21" y2="21"/></svg>
@elseif($i === 'bio')
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:100%;height:100%"><line x1="4" y1="7" x2="14" y2="7"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="17" x2="11" y2="17"/></svg>
@elseif($i === 'analytics')
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:100%;height:100%"><polyline points="3 17 9 11 13 15 21 7"/><polyline points="15 7 21 7 21 13"/></svg>
@elseif($i === 'domains')
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:100%;height:100%"><circle cx="12" cy="12" r="9"/><line x1="3" y1="12" x2="21" y2="12"/><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18"/></svg>
@elseif($i === 'settings')
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:100%;height:100%"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
@elseif($i === 'billing')
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:100%;height:100%"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>
@elseif($i === 'team')
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:100%;height:100%"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
@elseif($i === 'ai')
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:100%;height:100%"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
@endif