feat(ui): links index page with search, filter, delete; workspace route group

Add Links Livewire page (Index component + blade view) with live search,
status filter, paginated table, and delete action using DeleteLink for
cache invalidation. Register workspace-scoped route group under
w/{workspace} with ResolveWorkspace middleware. Add stub Livewire pages
for QrCodes, Bio, Analytics, Domains, Settings, and Billing with a shared
coming-soon view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-16 07:59:42 +02:00
parent 3929c48ea1
commit 61718fa867
10 changed files with 221 additions and 0 deletions

View File

@ -0,0 +1,14 @@
<?php
namespace App\Livewire\Pages\Analytics;
use Livewire\Component;
class Index extends Component
{
public function render(): \Illuminate\View\View
{
return view('livewire.pages.coming-soon')
->layout('layouts.nimuli-app', ['title' => 'Analytics']);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Livewire\Pages\Billing;
use Livewire\Component;
class Index extends Component
{
public function render(): \Illuminate\View\View
{
return view('livewire.pages.coming-soon')
->layout('layouts.nimuli-app', ['title' => 'Billing']);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Livewire\Pages\Bio;
use Livewire\Component;
class Index extends Component
{
public function render(): \Illuminate\View\View
{
return view('livewire.pages.coming-soon')
->layout('layouts.nimuli-app', ['title' => 'Bio Pages']);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Livewire\Pages\Domains;
use Livewire\Component;
class Index extends Component
{
public function render(): \Illuminate\View\View
{
return view('livewire.pages.coming-soon')
->layout('layouts.nimuli-app', ['title' => 'Domains']);
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Livewire\Pages\Links;
use App\Domains\Link\Actions\DeleteLink;
use App\Domains\Link\Models\Link;
use Livewire\Component;
use Livewire\WithPagination;
class Index extends Component
{
use WithPagination;
public string $search = '';
public string $statusFilter = '';
public function updatingSearch(): void
{
$this->resetPage();
}
public function updatingStatusFilter(): void
{
$this->resetPage();
}
public function deleteLink(int $id, DeleteLink $action): void
{
$workspace = app('current_workspace');
$link = Link::where('id', $id)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->authorize('update', $workspace);
$action->handle($link);
$this->resetPage();
}
public function render(): \Illuminate\View\View
{
$workspace = app('current_workspace');
$links = Link::where('workspace_id', $workspace->id)
->when($this->search, fn($q) => $q->where(function ($q) {
$q->where('title', 'like', "%{$this->search}%")
->orWhere('slug', 'like', "%{$this->search}%")
->orWhere('target_url', 'like', "%{$this->search}%");
}))
->when($this->statusFilter, fn($q) => $q->where('status', $this->statusFilter))
->latest()
->paginate(20);
return view('livewire.pages.links.index', compact('links'))
->layout('layouts.nimuli-app', ['title' => 'Links']);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Livewire\Pages\QrCodes;
use Livewire\Component;
class Index extends Component
{
public function render(): \Illuminate\View\View
{
return view('livewire.pages.coming-soon')
->layout('layouts.nimuli-app', ['title' => 'QR Codes']);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Livewire\Pages\Settings;
use Livewire\Component;
class Index extends Component
{
public function render(): \Illuminate\View\View
{
return view('livewire.pages.coming-soon')
->layout('layouts.nimuli-app', ['title' => 'Settings']);
}
}

View File

@ -0,0 +1 @@
<div class="text-center py-20 text-gray-500">Coming soon...</div>

View File

@ -0,0 +1,66 @@
<div>
<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">
+ New Link
</a>
</div>
<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" />
<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>
<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 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>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800">
@forelse($links as $link)
<tr class="hover:bg-gray-800/50 transition-colors">
<td class="px-4 py-3">
<div class="font-mono text-blue-400 text-sm">{{ $link->slug }}</div>
@if($link->title)
<div class="text-gray-500 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>
</td>
<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>
</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>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">{{ $links->links() }}</div>
</div>

View File

@ -16,6 +16,20 @@ Route::view('profile', 'profile')
->middleware(['auth']) ->middleware(['auth'])
->name('profile'); ->name('profile');
// Workspace-scoped routes — placed before slug catch-all
Route::middleware(['auth', 'verified', \App\Http\Middleware\ResolveWorkspace::class])
->prefix('w/{workspace}')
->name('w.')
->group(function () {
Route::get('/links', \App\Livewire\Pages\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');
Route::get('/analytics', \App\Livewire\Pages\Analytics\Index::class)->name('analytics.index');
Route::get('/domains', \App\Livewire\Pages\Domains\Index::class)->name('domains.index');
Route::get('/settings', \App\Livewire\Pages\Settings\Index::class)->name('settings.index');
Route::get('/billing', \App\Livewire\Pages\Billing\Index::class)->name('billing.index');
});
// Redirect service — handles slugs on main domain and custom domains // Redirect service — handles slugs on main domain and custom domains
// Placed after all named routes so dashboard/profile etc. are not caught here // Placed after all named routes so dashboard/profile etc. are not caught here
Route::get('/{slug}', \App\Http\Controllers\RedirectController::class) Route::get('/{slug}', \App\Http\Controllers\RedirectController::class)