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
parent
5ac6a198b4
commit
30989523cd
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ class Index extends Component
|
|||
|
||||
public string $statusFilter = '';
|
||||
|
||||
protected $listeners = ['linkCreated' => '$refresh'];
|
||||
|
||||
public function updatingSearch(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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']) !!}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
© {{ date('Y') }} Nimuli. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue