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
parent
3929c48ea1
commit
61718fa867
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<div class="text-center py-20 text-gray-500">Coming soon...</div>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue