nimuli/resources/views/components/sidebar.blade.php

304 lines
15 KiB
PHP

@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',
],
] : [
[
'label' => 'Dashboard',
'route' => 'dashboard',
'params' => [],
'match' => 'dashboard',
'icon' => 'dashboard',
],
];
// Plan usage
$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="{
'w-16': $store.sidebar.collapsed,
'w-60': !$store.sidebar.collapsed,
'translate-x-0': $store.sidebar?.open,
'-translate-x-full': !$store.sidebar?.open,
}"
class="fixed inset-y-0 left-0 h-screen flex flex-col bg-s1 border-r border-white/[.06] z-40 md:translate-x-0 transition-[width,transform] duration-200 ease-in-out"
>
<!-- Header: Logo + Workspace Switcher -->
<div class="flex-shrink-0 relative" @click.outside="wsOpen = false">
<!-- Collapsed state: centered logo acts as expand toggle -->
<div x-show="$store.sidebar.collapsed" class="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 x-show="!$store.sidebar.collapsed">
<button
@click="wsOpen = !wsOpen"
class="w-full flex items-center gap-3 px-4 py-4 hover:bg-s2 transition-colors border-b border-white/[.06]"
>
<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"/>
</svg>
</button>
<!-- Workspace Switcher Dropdown -->
<div
x-show="wsOpen"
x-transition:enter="transition ease-out duration-100"
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-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"
>
@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' }}"
>
<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>
@endif
</a>
@empty
<div class="px-4 py-2 text-xs text-t3">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"/>
</svg>
New Workspace
</button>
</div>
</div>
</div>
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-0.5">
@foreach($nav 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'] }}"
:title="$store.sidebar.collapsed ? '{{ $item['label'] }}' : ''"
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors group
{{ $isActive
? 'bg-blue/10 text-blue'
: 'text-t2 hover:text-t1 hover:bg-s2' }}"
>
<span class="w-4 h-4 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>
<span x-show="!$store.sidebar.collapsed" class="truncate">{{ $item['label'] }}</span>
</a>
@endforeach
</nav>
<!-- Collapse Toggle -->
<div class="flex-shrink-0 border-t border-white/[.06] px-2 py-2">
<button
@click="$store.sidebar.toggle()"
:title="$store.sidebar.collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
class="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-t3 hover:text-t1 hover:bg-s2 transition-colors"
>
<span class="w-4 h-4 flex-shrink-0 transition-transform duration-200" :class="$store.sidebar.collapsed ? 'rotate-180' : ''">
<svg 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>
</span>
<span x-show="!$store.sidebar.collapsed" class="text-xs">Collapse</span>
</button>
</div>
<!-- Plan Usage Box -->
@if($workspace && $plan)
<div x-show="!$store.sidebar.collapsed" class="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 -->
@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">
{{ $initials ?: strtoupper(substr(auth()->user()->email, 0, 2)) }}
</div>
<div x-show="!$store.sidebar.collapsed" class="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>
<form x-show="!$store.sidebar.collapsed" 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"/>
</svg>
</button>
</form>
</div>
@endauth
</aside>