feat: vollständiges Nimuli-Frontend Phase 1

Layouts:
- nimuli-app: Sidebar + Topbar + Mobile-Drawer (Alpine.js stores)
- guest: Nimuli-Brand, bg-bg, Inter/JetBrains Mono
- sidebar: Workspace-Switcher, Nav mit SVG-Icons, Plan-Usage-Box
- topbar: Theme-Toggle, Notifications-Bell, User-Dropdown

Auth (Breeze-Restyle mit Design-Tokens):
- Login: "Sign in" + dark-theme-native (text-t1, bg-s2, text-blue)
- Register: "Create account"
- Forgot Password: restyled
- Alle Input-Komponenten (text-input, label, button, errors) auf Tokens

Dashboard:
- Hero-Metrics (Today/7d/30d/Total Links)
- 30-Tage Chart.js Line-Chart
- Top-5-Links-Tabelle
- Quick-Actions

Links:
- Tabelle: nimu.li/-Prefix, font-mono slug, Tag-Badges, Status-Badges
- Create-Link Modal (Alpine.js + Livewire, auto-slug)
- Modal-Container-System

Stub-Pages (alle mit nimuli-app layout):
- QR Codes: Empty-State + CTA
- Link-in-Bio: Empty-State + CTA
- Analytics: Metrics + Top-Countries + By-Device
- Domains: Empty-State + CTA
- Settings: Workspace-Name + Danger-Zone
- Billing: Current-Plan + 3-Tier-Pricing-Cards

Demo-Daten:
- DemoDataSeeder: 20 Links + 957 Clicks (30 Tage)
- Workspace "Demo Agency" für nexxo@nimuli.com

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-16 10:52:52 +02:00
parent 5ac6a198b4
commit 30989523cd
34 changed files with 1240 additions and 196 deletions

View File

@ -0,0 +1,53 @@
<?php
namespace App\Livewire\Modals;
use App\Domains\Link\Models\Link;
use Livewire\Component;
use Illuminate\Support\Str;
class CreateLink extends Component
{
public string $targetUrl = '';
public string $slug = '';
public string $title = '';
protected $rules = [
'targetUrl' => 'required|url|max:2048',
'slug' => 'nullable|alpha_dash|max:50|unique:links,slug',
'title' => 'nullable|max:200',
];
public function generateSlug(): void
{
if (empty($this->slug)) {
$this->slug = Str::random(6);
}
}
public function save(): void
{
$this->validate();
$workspace = app('current_workspace');
if (empty($this->slug)) {
$this->slug = Str::random(6);
}
Link::create([
'workspace_id' => $workspace->id,
'target_url' => $this->targetUrl,
'slug' => $this->slug,
'title' => $this->title ?: null,
'status' => 'active',
]);
$this->dispatch('linkCreated');
$this->dispatch('close-modal');
}
public function render(): \Illuminate\View\View
{
return view('livewire.modals.create-link');
}
}

View File

@ -39,8 +39,25 @@ class Index extends Component
/** @var Workspace $workspace */
$workspace = app('current_workspace');
return view('livewire.pages.analytics.index', [
'workspace' => $workspace,
])->layout('layouts.nimuli-app', ['title' => 'Analytics']);
$totalClicks = Click::where('workspace_id', $workspace->id)->count();
$last30 = Click::where('workspace_id', $workspace->id)
->where('clicked_at', '>=', now()->subDays(30))->count();
$byCountry = Click::where('workspace_id', $workspace->id)
->whereNotNull('country')
->selectRaw('country, COUNT(*) as total')
->groupBy('country')
->orderByDesc('total')
->limit(5)
->get();
$byDevice = Click::where('workspace_id', $workspace->id)
->selectRaw('device, COUNT(*) as total')
->groupBy('device')
->orderByDesc('total')
->get();
return view('livewire.pages.analytics.index', compact('totalClicks', 'last30', 'byCountry', 'byDevice'))
->layout('layouts.nimuli-app', ['title' => 'Analytics']);
}
}

View File

@ -9,7 +9,7 @@ class Index extends Component
{
public function render(): View
{
return view('livewire.pages.coming-soon')
return view('livewire.pages.billing.index')
->layout('layouts.nimuli-app', ['title' => 'Billing']);
}
}

View File

@ -9,7 +9,7 @@ class Index extends Component
{
public function render(): View
{
return view('livewire.pages.coming-soon')
->layout('layouts.nimuli-app', ['title' => 'Bio Pages']);
return view('livewire.pages.bio.index')
->layout('layouts.nimuli-app', ['title' => 'Link-in-Bio']);
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Livewire\Pages\Dashboard;
use App\Domains\Analytics\Models\Click;
use App\Domains\Link\Models\Link;
use Illuminate\View\View;
use Livewire\Component;
class Overview extends Component
{
public function render(): View
{
$workspace = app('current_workspace');
// Hero metrics
$today = Click::where('workspace_id', $workspace->id)
->whereDate('clicked_at', today())
->count();
$last7 = Click::where('workspace_id', $workspace->id)
->where('clicked_at', '>=', now()->subDays(7))
->count();
$last30 = Click::where('workspace_id', $workspace->id)
->where('clicked_at', '>=', now()->subDays(30))
->count();
$totalLinks = Link::where('workspace_id', $workspace->id)->count();
// Chart data: daily clicks for last 30 days
$chartData = Click::where('workspace_id', $workspace->id)
->where('clicked_at', '>=', now()->subDays(29)->startOfDay())
->selectRaw('DATE(clicked_at) as date, COUNT(*) as total')
->groupBy('date')
->orderBy('date')
->pluck('total', 'date');
$labels = [];
$data = [];
for ($i = 29; $i >= 0; $i--) {
$date = now()->subDays($i)->toDateString();
$labels[] = now()->subDays($i)->format('d.m');
$data[] = $chartData[$date] ?? 0;
}
// Top links by clicks
$topLinks = Link::where('workspace_id', $workspace->id)
->withCount(['clicks as total_clicks'])
->orderByDesc('total_clicks')
->limit(5)
->get();
return view('livewire.pages.dashboard.overview', compact(
'today', 'last7', 'last30', 'totalLinks', 'labels', 'data', 'topLinks'
))->layout('layouts.nimuli-app', ['title' => 'Dashboard']);
}
}

View File

@ -9,7 +9,7 @@ class Index extends Component
{
public function render(): View
{
return view('livewire.pages.coming-soon')
->layout('layouts.nimuli-app', ['title' => 'Domains']);
return view('livewire.pages.domains.index')
->layout('layouts.nimuli-app', ['title' => 'Custom Domains']);
}
}

View File

@ -16,6 +16,8 @@ class Index extends Component
public string $statusFilter = '';
protected $listeners = ['linkCreated' => '$refresh'];
public function updatingSearch(): void
{
$this->resetPage();

View File

@ -9,7 +9,7 @@ class Index extends Component
{
public function render(): View
{
return view('livewire.pages.coming-soon')
return view('livewire.pages.qr-codes.index')
->layout('layouts.nimuli-app', ['title' => 'QR Codes']);
}
}

View File

@ -9,7 +9,7 @@ class Index extends Component
{
public function render(): View
{
return view('livewire.pages.coming-soon')
return view('livewire.pages.settings.index')
->layout('layouts.nimuli-app', ['title' => 'Settings']);
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace Database\Seeders;
use App\Domains\Analytics\Models\Click;
use App\Domains\Link\Models\Link;
use App\Domains\Workspace\Models\Workspace;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class DemoDataSeeder extends Seeder
{
public function run(): void
{
$user = User::where('email', 'nexxo@nimuli.com')->firstOrFail();
$workspace = Workspace::firstOrCreate(
['owner_id' => $user->id],
[
'name' => 'Demo Agency',
'slug' => 'demo-agency',
]
);
$this->command->info("Workspace: {$workspace->name} ({$workspace->ulid})");
$slugs = ['nimuli', 'get-started', 'pricing', 'blog', 'docs', 'github', 'twitter', 'linkedin', 'youtube', 'discord', 'product', 'api', 'signup', 'login-link', 'partner', 'careers', 'press', 'legal', 'status', 'support'];
$titles = ['Nimuli Homepage', 'Get Started Guide', 'Pricing Page', 'Blog', 'Documentation', 'GitHub Repo', 'Twitter/X', 'LinkedIn', 'YouTube Channel', 'Discord Community', 'Product Page', 'API Docs', 'Sign Up', 'Login Link', 'Partner Program', 'Careers', 'Press Kit', 'Legal', 'Status Page', 'Support'];
$targets = ['https://nimuli.com', 'https://docs.nimuli.com/start', 'https://nimuli.com/pricing', 'https://blog.nimuli.com', 'https://docs.nimuli.com', 'https://github.com/nimuli', 'https://twitter.com/nimuli', 'https://linkedin.com/company/nimuli', 'https://youtube.com/@nimuli', 'https://discord.gg/nimuli', 'https://nimuli.com/product', 'https://api.nimuli.com', 'https://app.nimuli.com/register', 'https://app.nimuli.com/login', 'https://nimuli.com/partners', 'https://nimuli.com/careers', 'https://nimuli.com/press', 'https://nimuli.com/legal', 'https://status.nimuli.com', 'https://support.nimuli.com'];
$links = [];
foreach ($slugs as $i => $slug) {
$link = Link::firstOrCreate(
['workspace_id' => $workspace->id, 'slug' => $slug],
[
'title' => $titles[$i],
'target_url' => $targets[$i],
'status' => $i < 18 ? 'active' : ($i === 18 ? 'disabled' : 'active'),
'tags' => $i < 5 ? ['marketing'] : ($i < 10 ? ['social'] : ['product']),
]
);
$links[] = $link;
}
$this->command->info('Created ' . count($links) . ' links');
$countries = ['DE', 'US', 'GB', 'FR', 'AT', 'CH', 'NL', 'PL', 'IT', 'ES'];
$devices = ['desktop', 'mobile', 'tablet'];
$browsers = ['Chrome', 'Firefox', 'Safari', 'Edge'];
$oses = ['Windows', 'macOS', 'Linux', 'iOS', 'Android'];
$clickCount = 0;
foreach ($links as $link) {
$baseClicks = random_int(10, 80);
for ($j = 0; $j < $baseClicks; $j++) {
$daysAgo = random_int(0, 29);
$hoursAgo = random_int(0, 23);
$clickedAt = now()->subDays($daysAgo)->subHours($hoursAgo);
Click::create([
'link_id' => $link->id,
'workspace_id' => $workspace->id,
'clicked_at' => $clickedAt,
'ip_hash' => hash('sha256', Str::random(16)),
'country' => $countries[array_rand($countries)],
'city' => null,
'device' => $devices[array_rand($devices)],
'os' => $oses[array_rand($oses)],
'browser' => $browsers[array_rand($browsers)],
'referrer_host' => null,
]);
$clickCount++;
}
}
$this->command->info("Created {$clickCount} clicks over 30 days");
$this->command->info("Dashboard URL: /w/{$workspace->ulid}/links");
}
}

View File

@ -1 +1,33 @@
import './bootstrap';
import Alpine from 'alpinejs';
import Chart from 'chart.js/auto';
window.Chart = Chart;
// Theme store — reads localStorage, applies to <html data-theme>
Alpine.store('theme', {
current: localStorage.getItem('nimuli_theme') || document.documentElement.dataset.theme || 'dark',
init() {
this.apply(this.current);
},
apply(theme) {
this.current = theme;
document.documentElement.dataset.theme = theme;
localStorage.setItem('nimuli_theme', theme);
},
cycle() {
const order = ['dark', 'light', 'system'];
const next = order[(order.indexOf(this.current) + 1) % order.length];
this.apply(next);
},
});
// Sidebar drawer store — mobile open/close
Alpine.store('sidebar', {
open: false,
});
window.Alpine = Alpine;
Alpine.start();

View File

@ -1,7 +1,6 @@
@props(['status'])
@if ($status)
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600 dark:text-green-400']) }}>
<div {{ $attributes->merge(['class' => 'mb-4 p-3 rounded-lg bg-green/10 border border-green/20 text-sm text-green']) }}>
{{ $status }}
</div>
@endif

View File

@ -1,9 +1,8 @@
@props(['messages'])
@props(['messages' => []])
@if ($messages)
<ul {{ $attributes->merge(['class' => 'text-sm text-red-600 dark:text-red-400 space-y-1']) }}>
<ul {{ $attributes->merge(['class' => 'mt-1.5 space-y-0.5']) }}>
@foreach ((array) $messages as $message)
<li>{{ $message }}</li>
<li class="text-xs text-red">{{ $message }}</li>
@endforeach
</ul>
@endif

View File

@ -1,5 +1,4 @@
@props(['value'])
<label {{ $attributes->merge(['class' => 'block font-medium text-sm text-gray-700 dark:text-gray-300']) }}>
@props(['value' => ''])
<label {{ $attributes->merge(['class' => 'block text-xs font-medium text-t2 mb-1.5']) }}>
{{ $value ?? $slot }}
</label>

View File

@ -0,0 +1,29 @@
<div
x-data="{ open: false, component: null }"
@open-modal.window="open = true; component = $event.detail.component"
@close-modal.window="open = false"
>
<div
x-show="open"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
style="display: none;"
>
<div class="absolute inset-0 bg-black/70" @click="open = false"></div>
<div class="relative bg-s1 border border-white/[.06] rounded-2xl shadow-2xl w-full max-w-md z-10"
x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95">
@livewire('modals.create-link')
</div>
</div>
</div>

View File

@ -1,3 +1,3 @@
<button {{ $attributes->merge(['type' => 'submit', 'class' => 'inline-flex items-center px-4 py-2 bg-gray-800 dark:bg-gray-200 border border-transparent rounded-md font-semibold text-xs text-white dark:text-gray-800 uppercase tracking-widest hover:bg-gray-700 dark:hover:bg-white focus:bg-gray-700 dark:focus:bg-white active:bg-gray-900 dark:active:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150']) }}>
<button {{ $attributes->merge(['class' => 'inline-flex items-center justify-center px-4 py-2.5 bg-blue hover:bg-blue/90 text-white text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue/50 focus:ring-offset-2 focus:ring-offset-s1 disabled:opacity-50 w-full']) }}>
{{ $slot }}
</button>

View File

@ -1,38 +1,246 @@
@php
$workspace = app()->bound('current_workspace') ? app('current_workspace') : null;
$nav = [
['label' => 'Dashboard', 'route' => 'dashboard', 'icon' => '⊞'],
['label' => 'Links', 'route' => 'links.index', 'icon' => '↗'],
['label' => 'QR Codes', 'route' => 'qr.index', 'icon' => '▦'],
['label' => 'Link-in-Bio', 'route' => 'bio.index', 'icon' => '≡'],
['label' => 'Analytics', 'route' => 'analytics.index', 'icon' => '↑'],
['label' => 'Domains', 'route' => 'domains.index', 'icon' => '◎'],
['label' => 'Settings', 'route' => 'settings.index', 'icon' => '⚙'],
$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' => '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 class="w-60 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
<div class="h-16 flex items-center px-5 border-b border-gray-800">
<span class="text-lg font-bold tracking-tight">Nimuli</span>
<aside
x-show="$store.sidebar.open || $el.closest('[data-desktop-sidebar]') !== null"
class="w-60 flex-shrink-0 bg-s1 border-r border-white/[.06] flex flex-col h-full"
data-desktop-sidebar
>
<!-- Header: Logo + Workspace Switcher -->
<div class="flex-shrink-0" x-data="{ wsOpen: false }">
<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]"
>
<div class="flex-1 text-left min-w-0">
<div class="text-sm font-bold text-t1 tracking-tight">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"
@click.outside="wsOpen = false"
>
@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">
<a href="#" class="flex items-center gap-2 px-4 py-2 text-sm text-t2 hover:text-t1 hover:bg-s3 transition-colors">
<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
</a>
</div>
</div>
</div>
@if($workspace)
<div class="px-3 py-3 border-b border-gray-800">
<div class="text-xs text-gray-500 uppercase tracking-wider mb-1">Workspace</div>
<div class="text-sm font-medium truncate">{{ $workspace->name }}</div>
</div>
@endif
<nav class="flex-1 px-2 py-3 space-y-0.5">
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-0.5">
@foreach($nav as $item)
<a href="{{ Route::has($item['route']) ? route($item['route']) : '#' }}"
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors
{{ request()->routeIs($item['route'] . '*') ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800' }}">
<span class="w-5 text-center">{{ $item['icon'] }}</span>
{{ $item['label'] }}
</a>
@php
$isActive = request()->routeIs($item['match']);
$url = Route::has($item['route']) ? route($item['route'], $item['params']) : '#';
@endphp
<a
href="{{ $url }}"
wire:navigate
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>
@endif
</span>
{{ $item['label'] }}
</a>
@endforeach
</nav>
@if($workspace?->plan)
<div class="px-3 py-3 border-t border-gray-800">
<div class="text-xs text-gray-500">Plan: {{ ucfirst($workspace->plan->name) }}</div>
</div>
<!-- Plan Usage Box -->
@if($workspace && $plan)
<div 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">
<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 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 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>

View File

@ -1,3 +1,5 @@
@props(['disabled' => false])
<input @disabled($disabled) {{ $attributes->merge(['class' => 'border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm']) }}>
<input
{{ $disabled ? 'disabled' : '' }}
{!! $attributes->merge(['class' => 'block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50 transition-colors disabled:opacity-50']) !!}
>

View File

@ -1,14 +1,131 @@
<header class="h-16 bg-gray-900 border-b border-gray-800 flex items-center justify-between px-6 flex-shrink-0">
<div class="flex items-center gap-3">
<span class="text-sm text-gray-400">{{ $title ?? '' }}</span>
@php
$user = auth()->user();
$userName = $user?->name ?? '';
$initials = collect(explode(' ', trim($userName)))->map(fn($w) => strtoupper(substr($w, 0, 1)))->take(2)->implode('');
if (!$initials && $user) {
$initials = strtoupper(substr($user->email, 0, 2));
}
@endphp
<header class="h-14 flex-shrink-0 bg-s1 border-b border-white/[.06] flex items-center justify-between px-4 gap-4">
<!-- Left: Hamburger (mobile) + Title -->
<div class="flex items-center gap-3 min-w-0">
<!-- Mobile hamburger -->
<button
class="md:hidden text-t2 hover:text-t1 transition-colors p-1 -ml-1"
@click="$store.sidebar.open = !$store.sidebar.open"
aria-label="Toggle sidebar"
>
<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="M3 6h14M3 10h14M3 14h14"/>
</svg>
</button>
<!-- Page title -->
@if(!empty($title))
<h1 class="text-sm font-medium text-t1 truncate">{{ $title }}</h1>
@endif
</div>
<div class="flex items-center gap-3">
@auth
<span class="text-sm text-gray-400">{{ auth()->user()->email }}</span>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="text-sm text-gray-400 hover:text-white transition-colors">Logout</button>
</form>
@endauth
<!-- Right: Actions -->
<div class="flex items-center gap-1 flex-shrink-0">
<!-- Theme Toggle -->
<button
@click="$store.theme.cycle()"
class="w-8 h-8 flex items-center justify-center rounded-lg text-t2 hover:text-t1 hover:bg-s2 transition-colors"
:title="'Theme: ' + $store.theme.current"
aria-label="Toggle theme"
>
<template x-if="$store.theme.current === 'dark'">
<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="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M3.5 3.5l1 1M11.5 11.5l1 1M11.5 4.5l1-1M3.5 12.5l1-1"/>
<circle cx="8" cy="8" r="3"/>
</svg>
</template>
<template x-if="$store.theme.current === 'light'">
<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="M8 3a5 5 0 100 10A5 5 0 008 3z" clip-rule="evenodd"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M11.5 4.5a5 5 0 01-7 7A5 5 0 0111.5 4.5z"/>
</svg>
</template>
<template x-if="$store.theme.current === 'system'">
<svg class="w-4 h-4" fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="12" height="9" rx="1.5"/><path stroke-linecap="round" stroke-linejoin="round" d="M5.5 14.5h5M8 11.5v3"/>
</svg>
</template>
</button>
<!-- Notifications Bell -->
<div class="relative">
<button
class="w-8 h-8 flex items-center justify-center rounded-lg text-t2 hover:text-t1 hover:bg-s2 transition-colors"
aria-label="Notifications"
>
<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="M8 1.5a5 5 0 00-5 5v2.5l-1 1.5h12l-1-1.5V6.5a5 5 0 00-5-5zM6.5 13.5a1.5 1.5 0 003 0"/>
</svg>
</button>
<span class="absolute top-1 right-1 w-3.5 h-3.5 bg-blue rounded-full text-white text-[9px] font-medium flex items-center justify-center leading-none pointer-events-none">3</span>
</div>
<!-- User Dropdown -->
<div class="relative ml-1" x-data="{ userOpen: false }">
<button
@click="userOpen = !userOpen"
class="w-8 h-8 rounded-full bg-blue flex items-center justify-center text-white text-xs font-semibold hover:opacity-90 transition-opacity"
aria-label="User menu"
>
{{ $initials }}
</button>
<!-- Dropdown -->
<div
x-show="userOpen"
@click.outside="userOpen = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95 translate-y-1"
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100 translate-y-0"
x-transition:leave-end="opacity-0 scale-95 translate-y-1"
class="absolute right-0 top-10 w-52 bg-s2 border border-white/[.06] rounded-xl shadow-xl py-1 z-50"
>
<!-- User info -->
<div class="px-4 py-2.5 border-b border-white/[.06]">
<div class="text-sm font-medium text-t1 truncate">{{ $userName }}</div>
<div class="text-xs text-t3 truncate">{{ $user?->email }}</div>
</div>
<!-- Links -->
<div class="py-1">
<a
href="{{ route('profile') }}"
wire:navigate
@click="userOpen = false"
class="flex items-center gap-2.5 px-4 py-2 text-sm text-t2 hover:text-t1 hover:bg-s3 transition-colors"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5">
<circle cx="8" cy="5" r="2.5"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.5 14c0-3 2.5-5 5.5-5s5.5 2 5.5 5"/>
</svg>
Profile
</a>
</div>
<div class="border-t border-white/[.06] py-1">
<form method="POST" action="{{ route('logout') }}">
@csrf
<button
type="submit"
class="w-full flex items-center gap-2.5 px-4 py-2 text-sm text-red hover:bg-s3 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>
Sign out
</button>
</form>
</div>
</div>
</div>
</div>
</header>

View File

@ -1,30 +1,41 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<title>{{ $title ?? config('app.name', 'Nimuli') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<!-- Fonts -->
<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=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans text-gray-900 antialiased">
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100 dark:bg-gray-900">
<div>
<a href="/" wire:navigate>
<x-application-logo class="w-20 h-20 fill-current text-gray-500" />
</a>
</div>
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-gray-800 shadow-md overflow-hidden sm:rounded-lg">
{{ $slot }}
</div>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>
<body class="bg-bg text-t1 font-sans antialiased">
<div class="min-h-screen flex flex-col items-center justify-center px-4 py-12">
<!-- Logo -->
<div class="mb-8 text-center">
<a href="/" wire:navigate class="inline-block">
<span class="text-2xl font-bold tracking-tight text-t1">nimuli.</span>
</a>
<p class="mt-2 text-sm text-t2">Short links, QR codes, and bio pages all in one place.</p>
</div>
</body>
<!-- Card -->
<div class="w-full max-w-sm bg-s1 border border-white/[.06] rounded-xl p-8 shadow-xl">
{{ $slot }}
</div>
<!-- Footer -->
<p class="mt-8 text-xs text-t3">
&copy; {{ date('Y') }} Nimuli. All rights reserved.
</p>
</div>
@livewireScripts
</body>
</html>

View File

@ -4,20 +4,51 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'Nimuli' }}</title>
<title>{{ ($title ?? '') ? $title . ' — Nimuli' : 'Nimuli' }}</title>
<!-- Fonts -->
<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=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>
<body class="bg-gray-950 text-gray-100 font-sans antialiased">
<body class="bg-bg text-t1 font-sans antialiased" x-data>
<div class="flex h-screen overflow-hidden">
<x-sidebar />
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Mobile Sidebar Overlay -->
<div
class="fixed inset-0 z-20 bg-black/60 md:hidden"
x-show="$store.sidebar?.open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="$store.sidebar.open = false"
aria-hidden="true"
></div>
<!-- Sidebar: always visible on md+, drawer on mobile -->
<div
class="fixed inset-y-0 left-0 z-30 md:relative md:z-auto md:flex md:flex-shrink-0 transition-transform duration-200 ease-in-out"
:class="$store.sidebar?.open ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
>
<x-sidebar />
</div>
<!-- Main Area -->
<div class="flex-1 flex flex-col overflow-hidden min-w-0">
<x-topbar :title="$title ?? ''" />
<main class="flex-1 overflow-y-auto p-6">
{{ $slot }}
</main>
</div>
</div>
<x-modal-container />
@livewireScripts
</body>
</html>

View File

@ -0,0 +1,41 @@
<div class="p-6">
<h3 class="text-lg font-semibold text-t1 mb-5">Create Short Link</h3>
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Target URL *</label>
<input wire:model="targetUrl" type="url" placeholder="https://example.com/long-url"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
@error('targetUrl') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Custom Slug (optional)</label>
<div class="flex items-center bg-s2 border border-white/[.06] rounded-lg overflow-hidden focus-within:ring-1 focus-within:ring-blue/50">
<span class="px-3 py-2.5 text-sm text-t3 border-r border-white/[.06] flex-shrink-0">nimu.li/</span>
<input wire:model="slug" type="text" placeholder="auto-generated"
class="flex-1 px-3 py-2.5 bg-transparent text-sm text-t1 placeholder-t3 focus:outline-none font-mono">
<button wire:click="generateSlug" type="button"
class="px-3 py-2.5 text-xs text-t2 hover:text-t1 border-l border-white/[.06] flex-shrink-0 transition-colors">Generate</button>
</div>
@error('slug') <p class="mt-1 text-xs text-red">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Title (optional)</label>
<input wire:model="title" type="text" placeholder="My awesome link"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50">
</div>
</div>
<div class="flex gap-3 mt-6">
<button wire:click="save"
class="flex-1 px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity">
Create Link
</button>
<button x-on:click="$dispatch('close-modal')"
class="px-4 py-2.5 bg-s2 text-t2 text-sm font-medium rounded-lg hover:text-t1 transition-colors border border-white/[.06]">
Cancel
</button>
</div>
</div>

View File

@ -1,25 +1,50 @@
<div>
<h1 class="text-2xl font-bold mb-6">Analytics</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div class="text-xs text-gray-500 uppercase tracking-wider mb-1">Today</div>
<div class="text-3xl font-bold font-mono">{{ $totalToday }}</div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold text-t1">Analytics</h1>
<div class="flex gap-2">
<button class="px-3 py-1.5 text-xs bg-blue text-white rounded-lg">30d</button>
<button class="px-3 py-1.5 text-xs bg-s2 text-t2 rounded-lg hover:text-t1 border border-white/[.06]">7d</button>
<button class="px-3 py-1.5 text-xs bg-s2 text-t2 rounded-lg hover:text-t1 border border-white/[.06]">90d</button>
</div>
</div>
@if(count($recentClicks) > 0)
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div class="px-4 py-3 border-b border-gray-800 text-sm font-medium">Recent clicks</div>
<div class="divide-y divide-gray-800">
@foreach($recentClicks as $click)
<div class="px-4 py-3 text-sm flex items-center gap-4">
<span class="text-gray-500 font-mono text-xs">{{ $click['clicked_at'] ?? '' }}</span>
<span class="text-gray-400">{{ $click['country'] ?? '—' }}</span>
<span class="text-gray-400">{{ $click['device'] ?? '—' }}</span>
{{-- Summary metrics --}}
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="bg-s1 border border-white/[.06] rounded-xl p-5">
<div class="text-xs font-medium text-t2 uppercase tracking-wider">Total Clicks</div>
<div class="text-3xl font-semibold font-mono text-t1 mt-2">{{ number_format($totalClicks) }}</div>
</div>
<div class="bg-s1 border border-white/[.06] rounded-xl p-5">
<div class="text-xs font-medium text-t2 uppercase tracking-wider">Last 30 Days</div>
<div class="text-3xl font-semibold font-mono text-t1 mt-2">{{ number_format($last30) }}</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{{-- Top Countries --}}
<div class="bg-s1 border border-white/[.06] rounded-xl p-5">
<h3 class="text-sm font-medium text-t1 mb-4">Top Countries</h3>
@forelse($byCountry as $row)
<div class="flex items-center justify-between py-2 border-b border-white/[.03] last:border-0">
<span class="text-sm text-t1">{{ $row->country }}</span>
<span class="text-sm font-mono text-t2">{{ number_format($row->total) }}</span>
</div>
@endforeach
@empty
<p class="text-sm text-t3">No data yet</p>
@endforelse
</div>
{{-- By Device --}}
<div class="bg-s1 border border-white/[.06] rounded-xl p-5">
<h3 class="text-sm font-medium text-t1 mb-4">By Device</h3>
@forelse($byDevice as $row)
<div class="flex items-center justify-between py-2 border-b border-white/[.03] last:border-0">
<span class="text-sm text-t1 capitalize">{{ $row->device }}</span>
<span class="text-sm font-mono text-t2">{{ number_format($row->total) }}</span>
</div>
@empty
<p class="text-sm text-t3">No data yet</p>
@endforelse
</div>
</div>
@endif
</div>

View File

@ -37,9 +37,8 @@ new #[Layout('layouts.guest')] class extends Component
}; ?>
<div>
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
{{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }}
</div>
<h2 class="text-xl font-semibold text-t1 mb-1">Reset password</h2>
<p class="text-sm text-t2 mb-6">Enter your email and we'll send you a reset link.</p>
<!-- Session Status -->
<x-auth-session-status class="mb-4" :status="session('status')" />
@ -48,14 +47,17 @@ new #[Layout('layouts.guest')] class extends Component
<!-- Email Address -->
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input wire:model="email" id="email" class="block mt-1 w-full" type="email" name="email" required autofocus />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
<x-text-input wire:model="email" id="email" type="email" name="email" required autofocus />
<x-input-error :messages="$errors->get('email')" />
</div>
<div class="flex items-center justify-end mt-4">
<x-primary-button>
{{ __('Email Password Reset Link') }}
</x-primary-button>
</div>
<x-primary-button class="mt-6">
Send reset link
</x-primary-button>
</form>
<p class="mt-6 text-center text-sm text-t2">
Remembered it?
<a href="{{ route('login') }}" class="text-blue hover:underline" wire:navigate>Back to sign in</a>
</p>
</div>

View File

@ -25,6 +25,9 @@ new #[Layout('layouts.guest')] class extends Component
}; ?>
<div>
<h2 class="text-xl font-semibold text-t1 mb-1">Sign in</h2>
<p class="text-sm text-t2 mb-6">Welcome back to Nimuli</p>
<!-- Session Status -->
<x-auth-session-status class="mb-4" :status="session('status')" />
@ -32,40 +35,38 @@ new #[Layout('layouts.guest')] class extends Component
<!-- Email Address -->
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input wire:model="form.email" id="email" class="block mt-1 w-full" type="email" name="email" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('form.email')" class="mt-2" />
<x-text-input wire:model="form.email" id="email" type="email" name="email" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('form.email')" />
</div>
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<x-text-input wire:model="form.password" id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="current-password" />
<x-input-error :messages="$errors->get('form.password')" class="mt-2" />
<x-text-input wire:model="form.password" id="password" type="password" name="password" required autocomplete="current-password" />
<x-input-error :messages="$errors->get('form.password')" />
</div>
<!-- Remember Me -->
<div class="block mt-4">
<label for="remember" class="inline-flex items-center">
<input wire:model="form.remember" id="remember" type="checkbox" class="rounded dark:bg-gray-900 border-gray-300 dark:border-gray-700 text-indigo-600 shadow-sm focus:ring-indigo-500 dark:focus:ring-indigo-600 dark:focus:ring-offset-gray-800" name="remember">
<span class="ms-2 text-sm text-gray-600 dark:text-gray-400">{{ __('Remember me') }}</span>
<!-- Remember Me + Forgot Password -->
<div class="flex items-center justify-between mt-4">
<label class="flex items-center gap-2 text-sm text-t2 cursor-pointer">
<input wire:model="form.remember" type="checkbox" class="rounded bg-s2 border-white/[.06] text-blue">
Remember me
</label>
</div>
<div class="flex items-center justify-end mt-4">
@if (Route::has('password.request'))
<a class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800" href="{{ route('password.request') }}" wire:navigate>
{{ __('Forgot your password?') }}
<a class="text-sm text-blue hover:underline" href="{{ route('password.request') }}" wire:navigate>
Forgot password?
</a>
@endif
<x-primary-button class="ms-3">
{{ __('Log in') }}
</x-primary-button>
</div>
<x-primary-button class="mt-6">
Sign in
</x-primary-button>
</form>
<p class="mt-6 text-center text-sm text-t2">
Don't have an account?
<a href="{{ route('register') }}" class="text-blue hover:underline" wire:navigate>Create one</a>
</p>
</div>

View File

@ -37,52 +37,45 @@ new #[Layout('layouts.guest')] class extends Component
}; ?>
<div>
<h2 class="text-xl font-semibold text-t1 mb-1">Create account</h2>
<p class="text-sm text-t2 mb-6">Start managing your links smarter</p>
<form wire:submit="register">
<!-- Name -->
<div>
<x-input-label for="name" :value="__('Name')" />
<x-text-input wire:model="name" id="name" class="block mt-1 w-full" type="text" name="name" required autofocus autocomplete="name" />
<x-input-error :messages="$errors->get('name')" class="mt-2" />
<x-text-input wire:model="name" id="name" type="text" name="name" required autofocus autocomplete="name" />
<x-input-error :messages="$errors->get('name')" />
</div>
<!-- Email Address -->
<div class="mt-4">
<x-input-label for="email" :value="__('Email')" />
<x-text-input wire:model="email" id="email" class="block mt-1 w-full" type="email" name="email" required autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
<x-text-input wire:model="email" id="email" type="email" name="email" required autocomplete="username" />
<x-input-error :messages="$errors->get('email')" />
</div>
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<x-text-input wire:model="password" id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
<x-text-input wire:model="password" id="password" type="password" name="password" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" />
</div>
<!-- Confirm Password -->
<div class="mt-4">
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
<x-text-input wire:model="password_confirmation" id="password_confirmation" class="block mt-1 w-full"
type="password"
name="password_confirmation" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
<x-text-input wire:model="password_confirmation" id="password_confirmation" type="password" name="password_confirmation" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password_confirmation')" />
</div>
<div class="flex items-center justify-end mt-4">
<a class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800" href="{{ route('login') }}" wire:navigate>
{{ __('Already registered?') }}
</a>
<x-primary-button class="ms-4">
{{ __('Register') }}
</x-primary-button>
</div>
<x-primary-button class="mt-6">
Create account
</x-primary-button>
</form>
<p class="mt-6 text-center text-sm text-t2">
Already have an account?
<a href="{{ route('login') }}" class="text-blue hover:underline" wire:navigate>Sign in</a>
</p>
</div>

View File

@ -0,0 +1,58 @@
<div>
<div class="mb-6">
<h1 class="text-2xl font-semibold text-t1">Billing</h1>
<p class="text-sm text-t2 mt-1">Manage your plan and billing</p>
</div>
{{-- Current Plan --}}
<div class="bg-s1 border border-white/[.06] rounded-xl p-6 mb-4">
<div class="flex items-center justify-between">
<div>
<div class="text-xs font-medium text-t2 uppercase tracking-wider mb-1">Current Plan</div>
<div class="text-xl font-semibold text-t1">Free</div>
<div class="text-sm text-t2 mt-1">20 / 50 links used</div>
</div>
<button class="px-4 py-2.5 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90">
Upgrade Plan
</button>
</div>
<div class="mt-4 bg-s3 rounded-full h-2">
<div class="bg-blue h-2 rounded-full" style="width: 40%"></div>
</div>
</div>
{{-- Plan Features --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
@foreach([
['name' => 'Free', 'price' => '€0', 'period' => '/mo', 'features' => ['50 links', '1 workspace', '1.000 clicks/mo', 'nimu.li domain'], 'current' => true],
['name' => 'Pro', 'price' => '€12', 'period' => '/mo', 'features' => ['Unlimited links', '3 workspaces', '50.000 clicks/mo', 'Custom domain', 'QR Codes', 'Bio pages'], 'current' => false],
['name' => 'Business', 'price' => '€49', 'period' => '/mo', 'features' => ['Unlimited everything', '10 workspaces', 'Unlimited clicks', 'API access', 'Team members', 'Priority support'], 'current' => false],
] as $plan)
<div class="bg-s1 border {{ $plan['current'] ? 'border-blue/40' : 'border-white/[.06]' }} rounded-xl p-5">
@if($plan['current'])
<div class="text-xs font-medium text-blue mb-2">Current plan</div>
@endif
<div class="text-lg font-semibold text-t1">{{ $plan['name'] }}</div>
<div class="mt-2 mb-4">
<span class="text-2xl font-bold text-t1">{{ $plan['price'] }}</span>
<span class="text-sm text-t2">{{ $plan['period'] }}</span>
</div>
<ul class="space-y-2 mb-5">
@foreach($plan['features'] as $feature)
<li class="flex items-center gap-2 text-sm text-t2">
<svg class="w-3.5 h-3.5 text-green 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>
{{ $feature }}
</li>
@endforeach
</ul>
@if(!$plan['current'])
<button class="w-full px-4 py-2 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90">
Upgrade to {{ $plan['name'] }}
</button>
@endif
</div>
@endforeach
</div>
</div>

View File

@ -0,0 +1,21 @@
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold text-t1">Link-in-Bio</h1>
<button class="px-4 py-2 bg-blue text-white rounded-lg text-sm font-medium hover:opacity-90">
+ New Bio Page
</button>
</div>
<div class="bg-s1 border border-white/[.06] rounded-xl p-12 text-center">
<div class="w-16 h-16 bg-s2 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-t3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5M3.75 17.25h7.5"/>
</svg>
</div>
<h3 class="text-lg font-medium text-t1 mb-2">No bio pages yet</h3>
<p class="text-sm text-t2 mb-6">Create a beautiful link-in-bio page for Instagram, TikTok and more.</p>
<button class="px-4 py-2.5 bg-blue text-white rounded-lg text-sm font-medium hover:opacity-90">
Create your first Bio Page
</button>
</div>
</div>

View File

@ -0,0 +1,130 @@
<div>
{{-- Quick Actions --}}
<div class="flex items-center gap-3 mb-6">
<a href="{{ route('w.links.index', request()->route('workspace')) }}"
class="px-4 py-2 bg-blue text-white rounded-lg text-sm font-medium hover:opacity-90 transition-opacity">
+ Create Link
</a>
<a href="{{ route('w.qr.index', request()->route('workspace')) }}"
class="px-4 py-2 bg-s2 text-t1 border border-white/[.06] rounded-lg text-sm font-medium hover:bg-s3 transition-colors">
+ Create QR Code
</a>
<a href="{{ route('w.analytics.index', request()->route('workspace')) }}"
class="px-4 py-2 text-t2 text-sm font-medium hover:text-t1 transition-colors">
View Analytics
</a>
</div>
{{-- Hero Metrics --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{{-- Clicks Today --}}
<div class="bg-s1 border border-white/[.06] rounded-xl p-5">
<div class="text-xs font-medium text-t2 uppercase tracking-wider">Clicks Today</div>
<div class="text-3xl font-semibold font-mono text-t1 mt-2">{{ number_format($today) }}</div>
<div class="text-xs text-t3 mt-1">since midnight</div>
</div>
{{-- Last 7 Days --}}
<div class="bg-s1 border border-white/[.06] rounded-xl p-5">
<div class="text-xs font-medium text-t2 uppercase tracking-wider">Last 7 Days</div>
<div class="text-3xl font-semibold font-mono text-t1 mt-2">{{ number_format($last7) }}</div>
<div class="text-xs text-t3 mt-1">rolling 7-day window</div>
</div>
{{-- Last 30 Days --}}
<div class="bg-s1 border border-white/[.06] rounded-xl p-5">
<div class="text-xs font-medium text-t2 uppercase tracking-wider">Last 30 Days</div>
<div class="text-3xl font-semibold font-mono text-t1 mt-2">{{ number_format($last30) }}</div>
<div class="text-xs text-t3 mt-1">rolling 30-day window</div>
</div>
{{-- Total Links --}}
<div class="bg-s1 border border-white/[.06] rounded-xl p-5">
<div class="text-xs font-medium text-t2 uppercase tracking-wider">Total Links</div>
<div class="text-3xl font-semibold font-mono text-t1 mt-2">{{ number_format($totalLinks) }}</div>
<div class="text-xs text-t3 mt-1">active links</div>
</div>
</div>
{{-- 30-Day Chart --}}
<div
class="bg-s1 border border-white/[.06] rounded-xl p-5 mt-4"
x-data
x-init="
const ctx = document.getElementById('clicksChart');
new Chart(ctx, {
type: 'line',
data: {
labels: {{ Js::from($labels) }},
datasets: [{
data: {{ Js::from($data) }},
borderColor: '#4f8ef7',
backgroundColor: 'rgba(79,142,247,0.08)',
fill: true,
tension: 0.4,
pointRadius: 0,
borderWidth: 2,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: {
grid: { color: 'rgba(255,255,255,0.04)' },
ticks: { color: '#8888a0', font: { size: 11 } }
},
y: {
grid: { color: 'rgba(255,255,255,0.04)' },
ticks: { color: '#8888a0', font: { size: 11 } },
beginAtZero: true
}
}
}
});
"
>
<h3 class="text-sm font-medium text-t1 mb-4">Clicks Last 30 Days</h3>
<div class="h-48">
<canvas id="clicksChart"></canvas>
</div>
</div>
{{-- Top 5 Links --}}
<div class="bg-s1 border border-white/[.06] rounded-xl mt-4 overflow-hidden">
<div class="px-5 py-4 border-b border-white/[.06]">
<h3 class="text-sm font-medium text-t1">Top Links</h3>
</div>
<table class="w-full text-sm">
<thead>
<tr class="text-xs font-medium text-t2 uppercase tracking-wider border-b border-white/[.06]">
<th class="px-5 py-3 text-left w-10">#</th>
<th class="px-5 py-3 text-left">Slug</th>
<th class="px-5 py-3 text-left">Title</th>
<th class="px-5 py-3 text-right">Clicks</th>
</tr>
</thead>
<tbody class="divide-y divide-white/[.04]">
@forelse($topLinks as $index => $link)
<tr class="hover:bg-s2/50 transition-colors">
<td class="px-5 py-3 text-t3 font-mono text-xs">{{ $index + 1 }}</td>
<td class="px-5 py-3">
<span class="font-mono text-blue text-sm">{{ $link->slug }}</span>
</td>
<td class="px-5 py-3 text-t2 text-sm truncate max-w-xs">
{{ $link->title ?: '—' }}
</td>
<td class="px-5 py-3 text-right font-mono text-t1 font-semibold">
{{ number_format($link->total_clicks) }}
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-5 py-10 text-center text-t3">No links yet.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>

View File

@ -0,0 +1,26 @@
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold text-t1">Custom Domains</h1>
<button class="px-4 py-2 bg-blue text-white rounded-lg text-sm font-medium hover:opacity-90">
+ Add Domain
</button>
</div>
<div class="bg-s1 border border-white/[.06] rounded-xl overflow-hidden">
<div class="px-5 py-4 border-b border-white/[.06]">
<p class="text-sm text-t2">Use your own domain for short links (e.g. <span class="font-mono text-t1">go.yourbrand.com</span>)</p>
</div>
<div class="p-12 text-center">
<div class="w-16 h-16 bg-s2 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-t3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253M3 12a8.954 8.954 0 01.527-3"/>
</svg>
</div>
<h3 class="text-lg font-medium text-t1 mb-2">No custom domains</h3>
<p class="text-sm text-t2 mb-6">Add a custom domain to brand your short links.</p>
<button class="px-4 py-2.5 bg-blue text-white rounded-lg text-sm font-medium hover:opacity-90">
Add your first domain
</button>
</div>
</div>
</div>

View File

@ -1,66 +1,114 @@
<div>
{{-- Header --}}
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Links</h1>
<a href="#" class="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">
<h1 class="text-2xl font-bold text-t1">Links</h1>
<button @click="$dispatch('open-modal', {component: 'modals.create-link'})"
class="px-4 py-2 bg-blue text-white rounded-lg text-sm font-medium hover:opacity-90 transition-opacity">
+ New Link
</a>
</button>
</div>
{{-- Filter Bar --}}
<div class="flex gap-3 mb-4">
<input wire:model.live.debounce.300ms="search"
type="text" placeholder="Search links..."
class="flex-1 px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-gray-100 text-sm focus:outline-none focus:border-blue-500" />
type="text"
placeholder="Search links..."
class="flex-1 px-3 py-2.5 bg-s1 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3 focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
<select wire:model.live="statusFilter"
class="px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-gray-100 text-sm focus:outline-none">
<option value="">All status</option>
class="px-3 py-2.5 bg-s1 border border-white/[.06] rounded-lg text-sm text-t2 focus:outline-none focus:ring-1 focus:ring-blue/50 cursor-pointer">
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="disabled">Disabled</option>
<option value="expired">Expired</option>
</select>
</div>
<div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
{{-- Table --}}
<div class="bg-s1 border border-white/[.06] rounded-xl overflow-hidden">
<table class="w-full text-sm">
<thead class="border-b border-gray-800">
<tr class="text-gray-500 text-xs uppercase tracking-wider">
<th class="px-4 py-3 text-left">Link</th>
<th class="px-4 py-3 text-left">Target</th>
<th class="px-4 py-3 text-right">Clicks</th>
<th class="px-4 py-3 text-center">Status</th>
<thead class="border-b border-white/[.06]">
<tr class="text-t3 text-xs uppercase tracking-wider">
<th class="px-4 py-3 text-left font-medium">Link</th>
<th class="px-4 py-3 text-left font-medium">Target</th>
<th class="px-4 py-3 text-left font-medium">Tags</th>
<th class="px-4 py-3 text-right font-medium">Clicks</th>
<th class="px-4 py-3 text-center font-medium">Status</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800">
<tbody class="divide-y divide-white/[.04]">
@forelse($links as $link)
<tr class="hover:bg-gray-800/50 transition-colors">
<tr class="hover:bg-white/[.02] transition-colors">
{{-- Link: slug + title --}}
<td class="px-4 py-3">
<div class="font-mono text-blue-400 text-sm">{{ $link->slug }}</div>
<div class="flex items-baseline gap-1">
<span class="text-t3 text-xs">nimu.li/</span><span class="font-mono text-blue text-sm">{{ $link->slug }}</span>
</div>
@if($link->title)
<div class="text-gray-500 text-xs mt-0.5">{{ $link->title }}</div>
<div class="text-t3 text-xs mt-0.5">{{ $link->title }}</div>
@endif
</td>
<td class="px-4 py-3 text-gray-400 truncate max-w-xs">{{ $link->target_url }}</td>
<td class="px-4 py-3 text-right font-mono text-gray-100">0</td>
<td class="px-4 py-3 text-center">
<span class="px-2 py-0.5 rounded-full text-xs font-medium
{{ $link->status === 'active' ? 'bg-green-500/10 text-green-400' : 'bg-gray-700 text-gray-400' }}">
{{ $link->status }}
</span>
{{-- Target URL --}}
<td class="px-4 py-3 text-t2 text-xs max-w-xs">
<span class="block truncate" title="{{ $link->target_url }}">{{ $link->target_url }}</span>
</td>
{{-- Tags --}}
<td class="px-4 py-3">
@if(!empty($link->tags))
<div class="flex flex-wrap gap-1">
@foreach($link->tags as $tag)
<span class="bg-s2 text-t3 text-xs px-2 py-0.5 rounded-full">{{ $tag }}</span>
@endforeach
</div>
@else
<span class="text-t3 text-xs"></span>
@endif
</td>
{{-- Clicks --}}
<td class="px-4 py-3 text-right font-mono text-t1 text-sm">
{{ $link->clicks_count ?? 0 }}
</td>
{{-- Status Badge --}}
<td class="px-4 py-3 text-center">
@if($link->status === 'active')
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green/10 text-green">active</span>
@elseif($link->status === 'expired')
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber/10 text-amber">expired</span>
@else
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-white/[.06] text-t3">{{ $link->status }}</span>
@endif
</td>
{{-- Actions --}}
<td class="px-4 py-3 text-right">
<button wire:click="deleteLink({{ $link->id }})"
wire:confirm="Delete this link?"
class="text-red-400 text-xs hover:opacity-80">Delete</button>
<div class="flex items-center justify-end gap-3">
<a href="#" class="text-t2 text-xs hover:text-t1 transition-colors">Edit</a>
<button wire:click="deleteLink({{ $link->id }})"
wire:confirm="Delete this link? This action cannot be undone."
class="text-red text-xs hover:opacity-80 transition-opacity">Delete</button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-12 text-center text-gray-500">No links yet. Create your first link.</td>
<td colspan="6" class="px-4 py-16 text-center">
<div class="flex flex-col items-center gap-3">
<svg class="w-10 h-10 text-t3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg>
<p class="text-t3 text-sm">No links yet. Create your first short link.</p>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- Pagination --}}
<div class="mt-4">{{ $links->links() }}</div>
</div>

View File

@ -0,0 +1,22 @@
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold text-t1">QR Codes</h1>
<button class="px-4 py-2 bg-blue text-white rounded-lg text-sm font-medium hover:opacity-90">
+ Create QR Code
</button>
</div>
<div class="bg-s1 border border-white/[.06] rounded-xl p-12 text-center">
<div class="w-16 h-16 bg-s2 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-t3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 6.75h.75v.75h-.75v-.75zM6.75 16.5h.75v.75h-.75v-.75zM16.5 6.75h.75v.75h-.75v-.75z"/>
</svg>
</div>
<h3 class="text-lg font-medium text-t1 mb-2">No QR Codes yet</h3>
<p class="text-sm text-t2 mb-6">Create QR codes for URLs, vCards, WiFi and more.</p>
<button class="px-4 py-2.5 bg-blue text-white rounded-lg text-sm font-medium hover:opacity-90">
Create your first QR Code
</button>
</div>
</div>

View File

@ -0,0 +1,32 @@
<div>
<div class="mb-6">
<h1 class="text-2xl font-semibold text-t1">Settings</h1>
<p class="text-sm text-t2 mt-1">Manage your workspace settings</p>
</div>
<div class="space-y-4">
{{-- Workspace Name --}}
<div class="bg-s1 border border-white/[.06] rounded-xl p-6">
<h3 class="text-sm font-medium text-t1 mb-4">Workspace</h3>
<div class="max-w-sm space-y-4">
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Name</label>
<input type="text" value="{{ app('current_workspace')?->name }}"
class="block w-full px-3 py-2.5 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 focus:outline-none focus:ring-1 focus:ring-blue/50">
</div>
<button class="px-4 py-2 bg-blue text-white text-sm font-medium rounded-lg hover:opacity-90">
Save Changes
</button>
</div>
</div>
{{-- Danger Zone --}}
<div class="bg-s1 border border-red/20 rounded-xl p-6">
<h3 class="text-sm font-medium text-red mb-2">Danger Zone</h3>
<p class="text-sm text-t2 mb-4">Once you delete a workspace, there is no going back.</p>
<button class="px-4 py-2 bg-red/10 text-red text-sm font-medium rounded-lg hover:bg-red/20 border border-red/20">
Delete Workspace
</button>
</div>
</div>
</div>

View File

@ -12,9 +12,15 @@ Route::get('/health', function () {
Route::view('/', 'welcome');
Route::view('dashboard', 'dashboard')
->middleware(['auth', 'verified'])
->name('dashboard');
Route::get('dashboard', function () {
// Redirect to first workspace or fall back to static view
$user = auth()->user();
$workspace = $user->workspaces()->first();
if ($workspace) {
return redirect()->route('w.dashboard', $workspace->ulid);
}
return view('dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');
Route::view('profile', 'profile')
->middleware(['auth'])
@ -25,6 +31,7 @@ Route::middleware(['auth', 'verified', ResolveWorkspace::class])
->prefix('w/{workspace}')
->name('w.')
->group(function () {
Route::get('/', App\Livewire\Pages\Dashboard\Overview::class)->name('dashboard');
Route::get('/links', Index::class)->name('links.index');
Route::get('/qr', App\Livewire\Pages\QrCodes\Index::class)->name('qr.index');
Route::get('/bio', App\Livewire\Pages\Bio\Index::class)->name('bio.index');