Refactor dashboard and upload flows
Remove dead admin UI code, redesign dashboard followers/following and upload experiences, and add schema audit tooling with repair migrations for forum and upload drift.
This commit is contained in:
@@ -1,108 +0,0 @@
|
||||
@extends('admin::layout.default')
|
||||
|
||||
@section('content')
|
||||
<x-page-layout>
|
||||
@include('admin::blocks.notification_error')
|
||||
|
||||
@if(session('msg_success'))
|
||||
<div class="alert alert-success alert-dismissible">
|
||||
<button type="button" class="close" data-dismiss="alert">×</button>
|
||||
{{ session('msg_success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-8">
|
||||
<h3 class="mb-1">Countries</h3>
|
||||
<p class="text-muted mb-0">Read-only ISO country catalog with manual sync support.</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-right">
|
||||
<form method="POST" action="{{ route('admin.cp.countries.sync') }}">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="fa-solid fa-rotate"></i> Sync countries
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<form method="GET" action="{{ route('admin.cp.countries.main') }}" class="form-inline">
|
||||
<div class="input-group input-group-sm" style="max-width: 420px; width: 100%;">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
value="{{ $search }}"
|
||||
placeholder="Search by code or name"
|
||||
class="form-control"
|
||||
/>
|
||||
<div class="input-group-append">
|
||||
<button type="submit" class="btn btn-default">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Country</th>
|
||||
<th>ISO2</th>
|
||||
<th>ISO3</th>
|
||||
<th>Region</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($countries as $country)
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center" style="gap: 10px;">
|
||||
@if ($country->local_flag_path)
|
||||
<img
|
||||
src="{{ $country->local_flag_path }}"
|
||||
alt="{{ $country->name_common }}"
|
||||
style="width: 24px; height: 16px; object-fit: cover; border-radius: 2px;"
|
||||
onerror="this.style.display='none'"
|
||||
>
|
||||
@endif
|
||||
<div>
|
||||
<div>{{ $country->name_common }}</div>
|
||||
@if ($country->name_official)
|
||||
<small class="text-muted">{{ $country->name_official }}</small>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><code>{{ $country->iso2 }}</code></td>
|
||||
<td><code>{{ $country->iso3 ?? '—' }}</code></td>
|
||||
<td>{{ $country->region ?? '—' }}</td>
|
||||
<td>
|
||||
@if ($country->active)
|
||||
<span class="badge badge-success">Active</span>
|
||||
@else
|
||||
<span class="badge badge-secondary">Inactive</span>
|
||||
@endif
|
||||
|
||||
@if ($country->is_featured)
|
||||
<span class="badge badge-info">Featured</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted py-4">No countries found.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card-footer clearfix">
|
||||
{{ $countries->links() }}
|
||||
</div>
|
||||
</div>
|
||||
</x-page-layout>
|
||||
@endsection
|
||||
@@ -1,99 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-6xl px-4 py-8">
|
||||
<div class="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-100">Countries</h1>
|
||||
<p class="mt-1 text-sm text-gray-400">Read-only ISO country catalog with manual sync support.</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('admin.countries.sync') }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sm text-sky-200">
|
||||
Sync countries
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if (session('success'))
|
||||
<div class="mb-4 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-200">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('error'))
|
||||
<div class="mb-4 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="get" action="{{ route('admin.countries.index') }}" class="mb-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
value="{{ $search }}"
|
||||
placeholder="Search by code or name"
|
||||
class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-100 placeholder:text-gray-500 md:max-w-sm"
|
||||
/>
|
||||
<button type="submit" class="rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-700 bg-gray-900">
|
||||
<table class="min-w-full divide-y divide-gray-700 text-sm">
|
||||
<thead class="bg-gray-800 text-gray-300">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Country</th>
|
||||
<th class="px-4 py-3 text-left">ISO2</th>
|
||||
<th class="px-4 py-3 text-left">ISO3</th>
|
||||
<th class="px-4 py-3 text-left">Region</th>
|
||||
<th class="px-4 py-3 text-left">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-800 text-gray-200">
|
||||
@forelse ($countries as $country)
|
||||
<tr>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@if ($country->local_flag_path)
|
||||
<img
|
||||
src="{{ $country->local_flag_path }}"
|
||||
alt="{{ $country->name_common }}"
|
||||
class="h-4 w-6 rounded-sm object-cover"
|
||||
onerror="this.style.display='none'"
|
||||
>
|
||||
@endif
|
||||
<div>
|
||||
<div>{{ $country->name_common }}</div>
|
||||
@if ($country->name_official)
|
||||
<div class="text-xs text-gray-500">{{ $country->name_official }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono">{{ $country->iso2 }}</td>
|
||||
<td class="px-4 py-3 font-mono">{{ $country->iso3 ?? '—' }}</td>
|
||||
<td class="px-4 py-3">{{ $country->region ?? '—' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-1 text-xs {{ $country->active ? 'bg-emerald-500/10 text-emerald-200' : 'bg-gray-700 text-gray-300' }}">
|
||||
{{ $country->active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
@if ($country->is_featured)
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-sky-500/10 px-2.5 py-1 text-xs text-sky-200">Featured</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-6 text-center text-gray-400">No countries found.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $countries->links() }}</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,156 +0,0 @@
|
||||
@extends('layouts.nova.content-layout')
|
||||
|
||||
@section('page-content')
|
||||
<div class="max-w-4xl space-y-8">
|
||||
|
||||
{{-- ── Header ── --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white">Early-Stage Growth System</h2>
|
||||
<p class="mt-1 text-sm text-neutral-400">
|
||||
A non-deceptive layer that keeps Nova feeling alive when uploads are sparse.
|
||||
Toggle via <code class="text-sky-400">.env</code> — no deployment required for mode changes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Cache flush button --}}
|
||||
<form method="POST" action="{{ route('admin.early-growth.cache.flush') }}" onsubmit="return confirm('Flush all EGS caches?')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-neutral-800 px-4 py-2 text-sm font-medium text-white
|
||||
hover:bg-neutral-700 border border-neutral-700 transition">
|
||||
🔄 Flush EGS Cache
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="rounded-lg bg-green-900/40 border border-green-700 px-4 py-3 text-green-300 text-sm">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ── Live Status ── --}}
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-4">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">Live Status</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
@php
|
||||
$pill = fn(bool $on) => $on
|
||||
? '<span class="inline-block rounded-full bg-emerald-800 px-3 py-0.5 text-xs font-semibold text-emerald-200">ON</span>'
|
||||
: '<span class="inline-block rounded-full bg-neutral-700 px-3 py-0.5 text-xs font-semibold text-neutral-400">OFF</span>';
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">System</p>
|
||||
{!! $pill($status['enabled']) !!}
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Mode</p>
|
||||
<span class="text-sm font-mono font-semibold {{ $mode === 'aggressive' ? 'text-amber-400' : ($mode === 'light' ? 'text-sky-400' : 'text-neutral-400') }}">
|
||||
{{ strtoupper($mode) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Adaptive Window</p>
|
||||
{!! $pill($status['adaptive_window']) !!}
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Grid Filler</p>
|
||||
{!! $pill($status['grid_filler']) !!}
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Spotlight</p>
|
||||
{!! $pill($status['spotlight']) !!}
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Activity Layer</p>
|
||||
{!! $pill($status['activity_layer']) !!}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Upload Stats ── --}}
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-4">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">Upload Metrics</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Uploads / day (7-day avg)</p>
|
||||
<p class="text-2xl font-bold text-white">{{ number_format($uploads_per_day, 1) }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Active trending window</p>
|
||||
<p class="text-2xl font-bold text-white">{{ $window_days }}d</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Activity Signals ── --}}
|
||||
@if($status['activity_layer'] && !empty($activity))
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-3">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">Activity Signals</h3>
|
||||
<ul class="space-y-2">
|
||||
@foreach($activity as $signal)
|
||||
<li class="text-sm text-neutral-200">{{ $signal['icon'] }} {{ $signal['text'] }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ── ENV Toggles ── --}}
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-4">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">ENV Configuration</h3>
|
||||
<p class="text-xs text-neutral-500">Edit <code class="text-sky-400">.env</code> to change these values. Run <code class="text-sky-400">php artisan config:clear</code> after changes.</p>
|
||||
<div class="overflow-hidden rounded-lg border border-neutral-800">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 bg-neutral-800/40">
|
||||
<th class="px-4 py-2 text-left text-xs text-neutral-400">Variable</th>
|
||||
<th class="px-4 py-2 text-left text-xs text-neutral-400">Current Value</th>
|
||||
<th class="px-4 py-2 text-left text-xs text-neutral-400">Effect</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($env_toggles as $t)
|
||||
<tr class="border-b border-neutral-800/50">
|
||||
<td class="px-4 py-2 font-mono text-sky-400">{{ $t['key'] }}</td>
|
||||
<td class="px-4 py-2 font-mono text-white">{{ $t['current'] }}</td>
|
||||
<td class="px-4 py-2 text-neutral-400">
|
||||
@switch($t['key'])
|
||||
@case('NOVA_EARLY_GROWTH_ENABLED') Master switch. Set to <code>false</code> to disable entire system. @break
|
||||
@case('NOVA_EARLY_GROWTH_MODE') <code>off</code> / <code>light</code> / <code>aggressive</code> @break
|
||||
@case('NOVA_EGS_ADAPTIVE_WINDOW') Widen trending window when uploads low. @break
|
||||
@case('NOVA_EGS_GRID_FILLER') Backfill page-1 grids to 12 items. @break
|
||||
@case('NOVA_EGS_SPOTLIGHT') Daily-rotating curated picks. @break
|
||||
@case('NOVA_EGS_ACTIVITY_LAYER') Real activity summary badges. @break
|
||||
@endswitch
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-neutral-800/30 border border-neutral-700 p-4 text-xs text-neutral-400 space-y-1">
|
||||
<p><strong class="text-white">To enable (light mode):</strong></p>
|
||||
<pre class="text-sky-400 font-mono">NOVA_EARLY_GROWTH_ENABLED=true
|
||||
NOVA_EARLY_GROWTH_MODE=light</pre>
|
||||
<p class="mt-2"><strong class="text-white">To disable instantly:</strong></p>
|
||||
<pre class="text-sky-400 font-mono">NOVA_EARLY_GROWTH_ENABLED=false</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Cache Keys Reference ── --}}
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-3">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">Cache Keys</h3>
|
||||
<ul class="space-y-1">
|
||||
@foreach($cache_keys as $key)
|
||||
<li class="font-mono text-xs text-neutral-400">{{ $key }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<p class="text-xs text-neutral-600">Use the "Flush EGS Cache" button above to clear these in one action.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,24 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8 py-10">
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6">
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Reports Hub</h1>
|
||||
<p class="mt-2 text-sm text-gray-500">Internal reporting entry points for moderation and discovery analytics.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<a href="{{ route('admin.reports.tags') }}" class="rounded-xl border border-sky-200 bg-sky-50 p-6 transition hover:border-sky-300 hover:bg-sky-100/80 dark:border-sky-900/60 dark:bg-sky-950/30 dark:hover:border-sky-700 dark:hover:bg-sky-950/50">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-sky-100">Tag Interaction Report</h2>
|
||||
<p class="mt-2 text-sm text-slate-600 dark:text-sky-200/70">Inspect top surfaces, tags, search terms, and related-tag transitions from the new tag analytics pipeline.</p>
|
||||
</a>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Moderation Queue</h2>
|
||||
<p class="mt-2 text-sm text-gray-500">Use the API endpoint <code>/api/reports</code> to submit reports and review records in the <code>reports</code> table.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,216 +0,0 @@
|
||||
@extends('layouts.nova.content-layout')
|
||||
|
||||
@section('page-content')
|
||||
<div class="mx-auto max-w-7xl space-y-8">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Tag Interaction Report</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm text-neutral-400">
|
||||
Internal dashboard for tag discovery clicks. Use it to inspect surface performance, top tags, query demand, and tag-to-tag transitions for recommendation tuning.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3 text-xs">
|
||||
<a href="{{ route('api.admin.reports.tags', request()->query()) }}" class="inline-flex items-center rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2 font-medium text-neutral-200 transition hover:border-sky-500 hover:text-white">JSON report</a>
|
||||
<a href="{{ route('admin.reports.queue') }}" class="inline-flex items-center rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2 font-medium text-neutral-200 transition hover:border-sky-500 hover:text-white">Reports hub</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<form method="GET" action="{{ route('admin.reports.tags') }}" class="grid gap-4 md:grid-cols-4">
|
||||
<label class="space-y-2 text-sm text-neutral-300">
|
||||
<span class="block text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">From</span>
|
||||
<input type="date" name="from" value="{{ $filters['from'] }}" class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-white focus:border-sky-500 focus:outline-none">
|
||||
</label>
|
||||
<label class="space-y-2 text-sm text-neutral-300">
|
||||
<span class="block text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">To</span>
|
||||
<input type="date" name="to" value="{{ $filters['to'] }}" class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-white focus:border-sky-500 focus:outline-none">
|
||||
</label>
|
||||
<label class="space-y-2 text-sm text-neutral-300">
|
||||
<span class="block text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Row limit</span>
|
||||
<input type="number" min="1" max="100" name="limit" value="{{ $filters['limit'] }}" class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-white focus:border-sky-500 focus:outline-none">
|
||||
</label>
|
||||
<div class="flex items-end gap-3">
|
||||
<button type="submit" class="inline-flex items-center rounded-lg bg-sky-500 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-400">Refresh</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-4 text-xs text-neutral-500">
|
||||
<span>Latest aggregated date: <span class="font-medium text-neutral-300">{{ $latestAggregatedDate ?? 'not aggregated yet' }}</span></span>
|
||||
<span>Latest raw event: <span class="font-medium text-neutral-300">{{ $overview['latest_event_at'] ?? 'n/a' }}</span></span>
|
||||
</div>
|
||||
|
||||
@if(app()->environment('local'))
|
||||
<div class="mt-4 rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-100">
|
||||
<p class="font-semibold">Local demo data</p>
|
||||
<p class="mt-1 text-amber-100/80">
|
||||
This report can be filled locally with seeded click data. Run
|
||||
<code class="rounded bg-black/30 px-2 py-1 text-xs text-amber-50">php artisan analytics:seed-tag-interaction-demo --days=14 --per-day=80 --refresh</code>
|
||||
and refresh this page to inspect realistic search, recommendation, and transition metrics.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Total clicks</p>
|
||||
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['total_clicks']) }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Unique users</p>
|
||||
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['unique_users']) }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Unique sessions</p>
|
||||
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['unique_sessions']) }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Distinct tags</p>
|
||||
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['distinct_tags']) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Daily Click Trend</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500">Daily rollups for tuning trending and recommendation decisions.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@forelse($dailyClicks as $row)
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-950/70 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-neutral-500">{{ $row['date'] }}</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($row['clicks']) }}</p>
|
||||
<p class="mt-1 text-xs text-neutral-500">clicks</p>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-neutral-500">No aggregated rows available for the selected range yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<h2 class="text-lg font-semibold text-white">Top Surfaces</h2>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
|
||||
<th class="pb-3 pr-4">Surface</th>
|
||||
<th class="pb-3 pr-4">Clicks</th>
|
||||
<th class="pb-3 pr-4">Users</th>
|
||||
<th class="pb-3 pr-4">Sessions</th>
|
||||
<th class="pb-3">Avg pos.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($bySurface as $row)
|
||||
<tr class="border-b border-neutral-800/70 text-neutral-200">
|
||||
<td class="py-3 pr-4 font-medium text-white">{{ $row['surface'] }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['unique_users']) }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['unique_sessions']) }}</td>
|
||||
<td class="py-3">{{ $row['avg_position'] > 0 ? number_format($row['avg_position'], 2) : 'n/a' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="py-4 text-neutral-500">No surface data in this range.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<h2 class="text-lg font-semibold text-white">Top Query Terms</h2>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
|
||||
<th class="pb-3 pr-4">Query</th>
|
||||
<th class="pb-3 pr-4">Clicks</th>
|
||||
<th class="pb-3 pr-4">Sessions</th>
|
||||
<th class="pb-3">Resolved tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($topQueries as $row)
|
||||
<tr class="border-b border-neutral-800/70 text-neutral-200">
|
||||
<td class="py-3 pr-4 font-medium text-white">{{ $row['query'] }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['unique_sessions']) }}</td>
|
||||
<td class="py-3">{{ number_format($row['resolved_tags']) }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4" class="py-4 text-neutral-500">No query data in this range.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<h2 class="text-lg font-semibold text-white">Top Tags</h2>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
|
||||
<th class="pb-3 pr-4">Tag</th>
|
||||
<th class="pb-3 pr-4">Clicks</th>
|
||||
<th class="pb-3 pr-4">Recommendation</th>
|
||||
<th class="pb-3 pr-4">Search</th>
|
||||
<th class="pb-3">Sessions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($topTags as $row)
|
||||
<tr class="border-b border-neutral-800/70 text-neutral-200">
|
||||
<td class="py-3 pr-4 font-medium text-white">#{{ $row['tag_slug'] }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['recommendation_clicks']) }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['search_clicks']) }}</td>
|
||||
<td class="py-3">{{ number_format($row['unique_sessions']) }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="py-4 text-neutral-500">No tag click data in this range.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||
<h2 class="text-lg font-semibold text-white">Top Tag Transitions</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500">Most-clicked source tag to target tag paths from related-tag surfaces.</p>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
|
||||
<th class="pb-3 pr-4">Source</th>
|
||||
<th class="pb-3 pr-4">Target</th>
|
||||
<th class="pb-3 pr-4">Clicks</th>
|
||||
<th class="pb-3 pr-4">Sessions</th>
|
||||
<th class="pb-3">Avg pos.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($topTransitions as $row)
|
||||
<tr class="border-b border-neutral-800/70 text-neutral-200">
|
||||
<td class="py-3 pr-4 font-medium text-white">#{{ $row['source_tag_slug'] }}</td>
|
||||
<td class="py-3 pr-4 font-medium text-white">#{{ $row['tag_slug'] }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
|
||||
<td class="py-3 pr-4">{{ number_format($row['unique_sessions']) }}</td>
|
||||
<td class="py-3">{{ $row['avg_position'] > 0 ? number_format($row['avg_position'], 2) : 'n/a' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="py-4 text-neutral-500">No transition data in this range.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,36 +0,0 @@
|
||||
@extends('layouts.nova.content-layout')
|
||||
|
||||
@section('page-content')
|
||||
<div class="max-w-5xl">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Staff / Contact Submissions</h2>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-neutral-800 bg-nova-900 p-4">
|
||||
<table class="w-full table-auto text-left text-sm">
|
||||
<thead>
|
||||
<tr class="text-neutral-400">
|
||||
<th class="py-2">When</th>
|
||||
<th class="py-2">Topic</th>
|
||||
<th class="py-2">Name</th>
|
||||
<th class="py-2">Email</th>
|
||||
<th class="py-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($items as $i)
|
||||
<tr class="border-t border-neutral-800">
|
||||
<td class="py-3 text-neutral-400">{{ $i->created_at->toDayDateTimeString() }}</td>
|
||||
<td class="py-3">{{ ucfirst($i->topic) }}</td>
|
||||
<td class="py-3">{{ $i->name }}</td>
|
||||
<td class="py-3">{{ $i->email }}</td>
|
||||
<td class="py-3"><a class="text-sky-400 hover:underline" href="{{ route('admin.applications.show', $i->id) }}">View</a></td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="py-6 text-neutral-400">No submissions yet.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="mt-4">{{ $items->links() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,36 +0,0 @@
|
||||
@extends('layouts.nova.content-layout')
|
||||
|
||||
@section('page-content')
|
||||
<div class="max-w-3xl">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Submission</h2>
|
||||
|
||||
<div class="rounded-lg border border-neutral-800 bg-nova-900 p-6">
|
||||
<dl class="grid grid-cols-1 gap-4 text-sm text-neutral-300">
|
||||
<div>
|
||||
<dt class="text-neutral-400">Topic</dt>
|
||||
<dd>{{ ucfirst($item->topic) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-neutral-400">Name</dt>
|
||||
<dd>{{ $item->name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-neutral-400">Email</dt>
|
||||
<dd>{{ $item->email }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-neutral-400">Portfolio</dt>
|
||||
<dd>{{ $item->portfolio }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-neutral-400">Message</dt>
|
||||
<dd class="whitespace-pre-line">{{ $item->message }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-neutral-400">Received</dt>
|
||||
<dd>{{ $item->created_at->toDayDateTimeString() }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,8 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
<h1 class="mb-2 text-xl font-semibold text-gray-100">Story Comments Moderation</h1>
|
||||
<p class="text-sm text-gray-400">Story comments currently use the existing profile comment pipeline. Use this page as moderation entrypoint and link to the global comments moderation tools.</p>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,8 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-100">Create Story</h1>
|
||||
@include('admin.stories.partials.form', ['action' => route('admin.stories.store'), 'method' => 'POST'])
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,21 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-100">Edit Story</h1>
|
||||
@include('admin.stories.partials.form', ['action' => route('admin.stories.update', $story->id), 'method' => 'PUT'])
|
||||
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<form method="POST" action="{{ route('admin.stories.publish', $story->id) }}">
|
||||
@csrf
|
||||
<button class="rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-4 py-2 text-emerald-200">Publish</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('admin.stories.destroy', $story->id) }}" onsubmit="return confirm('Delete this story?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2 text-rose-200">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,42 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-6xl px-4 py-8">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-100">Stories Management</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ route('admin.stories.review') }}" class="rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-200">Review queue</a>
|
||||
<a href="{{ route('admin.stories.create') }}" class="rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sm text-sky-200">Create story</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-700 bg-gray-900">
|
||||
<table class="min-w-full divide-y divide-gray-700 text-sm">
|
||||
<thead class="bg-gray-800 text-gray-300">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Title</th>
|
||||
<th class="px-4 py-3 text-left">Creator</th>
|
||||
<th class="px-4 py-3 text-left">Status</th>
|
||||
<th class="px-4 py-3 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-800 text-gray-200">
|
||||
@foreach($stories as $story)
|
||||
<tr>
|
||||
<td class="px-4 py-3">{{ $story->title }}</td>
|
||||
<td class="px-4 py-3">{{ $story->creator?->username ?? 'n/a' }}</td>
|
||||
<td class="px-4 py-3">{{ $story->status }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="{{ route('admin.stories.show', ['story' => $story->id]) }}" class="text-amber-300">View</a>
|
||||
<span class="mx-1 text-gray-500">|</span>
|
||||
<a href="{{ route('admin.stories.edit', ['story' => $story->id]) }}" class="text-sky-300">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $stories->links() }}</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,71 +0,0 @@
|
||||
<form method="POST" action="{{ $action }}" class="space-y-5 rounded-xl border border-gray-700 bg-gray-800/60 p-6">
|
||||
@csrf
|
||||
@if($method !== 'POST')
|
||||
@method($method)
|
||||
@endif
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-200">Creator</label>
|
||||
<select name="creator_id" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white" required>
|
||||
@foreach($creators as $creator)
|
||||
<option value="{{ $creator->id }}" @selected(old('creator_id', $story->creator_id ?? '') == $creator->id)>{{ $creator->username }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-200">Status</label>
|
||||
<select name="status" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white" required>
|
||||
@foreach(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'] as $status)
|
||||
<option value="{{ $status }}" @selected(old('status', $story->status ?? 'draft') === $status)>{{ ucfirst($status) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-200">Title</label>
|
||||
<input name="title" value="{{ old('title', $story->title ?? '') }}" required class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-200">Cover image URL</label>
|
||||
<input name="cover_image" value="{{ old('cover_image', $story->cover_image ?? '') }}" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-200">Excerpt</label>
|
||||
<textarea name="excerpt" rows="3" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white">{{ old('excerpt', $story->excerpt ?? '') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-200">Story type</label>
|
||||
<select name="story_type" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white" required>
|
||||
@foreach(['creator_story', 'tutorial', 'interview', 'project_breakdown', 'announcement', 'resource'] as $type)
|
||||
<option value="{{ $type }}" @selected(old('story_type', $story->story_type ?? 'creator_story') === $type)>{{ str_replace('_', ' ', ucfirst($type)) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-200">Tags</label>
|
||||
<select name="tags[]" multiple class="min-h-24 w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white">
|
||||
@php
|
||||
$selectedTags = collect(old('tags', isset($story) ? $story->tags->pluck('id')->all() : []))->map(fn($id) => (int) $id)->all();
|
||||
@endphp
|
||||
@foreach($tags as $tag)
|
||||
<option value="{{ $tag->id }}" @selected(in_array($tag->id, $selectedTags, true))>{{ $tag->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-gray-200">Content</label>
|
||||
<textarea name="content" rows="14" required class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white">{{ old('content', $story->content ?? '') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="rounded-lg border border-sky-500/40 bg-sky-500/10 px-4 py-2 text-sky-200">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,46 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-6xl px-4 py-8">
|
||||
<div class="mb-5 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-100">Stories Review Queue</h1>
|
||||
<p class="text-sm text-gray-300">Pending creator stories waiting for moderation.</p>
|
||||
</div>
|
||||
<a href="{{ route('admin.stories.index') }}" class="rounded-lg border border-gray-600 px-3 py-2 text-sm text-gray-200">All stories</a>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-700 bg-gray-900">
|
||||
<table class="min-w-full divide-y divide-gray-700 text-sm">
|
||||
<thead class="bg-gray-800 text-gray-300">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Story</th>
|
||||
<th class="px-4 py-3 text-left">Creator</th>
|
||||
<th class="px-4 py-3 text-left">Submitted</th>
|
||||
<th class="px-4 py-3 text-left">Status</th>
|
||||
<th class="px-4 py-3 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-800 text-gray-200">
|
||||
@forelse($stories as $story)
|
||||
<tr>
|
||||
<td class="px-4 py-3">{{ $story->title }}</td>
|
||||
<td class="px-4 py-3">{{ $story->creator?->username ?? 'n/a' }}</td>
|
||||
<td class="px-4 py-3">{{ optional($story->submitted_for_review_at)->diffForHumans() ?? optional($story->updated_at)->diffForHumans() }}</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full border border-amber-500/40 px-2 py-1 text-xs text-amber-200">{{ $story->status }}</span></td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="{{ route('admin.stories.show', ['story' => $story->id]) }}" class="text-sky-300 hover:text-sky-200">Review</a>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-6 text-center text-gray-400">No stories pending review.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $stories->links() }}</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,49 +0,0 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-6xl px-4 py-8">
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-semibold text-gray-100">Review Story</h1>
|
||||
<a href="{{ route('admin.stories.review') }}" class="rounded-lg border border-gray-600 px-3 py-2 text-sm text-gray-200">Back to queue</a>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-12">
|
||||
<article class="lg:col-span-8 rounded-xl border border-gray-700 bg-gray-900/80 p-5">
|
||||
<p class="text-xs uppercase tracking-wide text-gray-400">{{ $story->story_type }}</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold text-white">{{ $story->title }}</h2>
|
||||
<p class="mt-1 text-sm text-gray-300">Creator: @{{ $story->creator?->username ?? 'unknown' }}</p>
|
||||
@if($story->excerpt)
|
||||
<p class="mt-3 text-sm text-gray-200">{{ $story->excerpt }}</p>
|
||||
@endif
|
||||
<div class="prose prose-invert mt-5 max-w-none prose-a:text-sky-300">
|
||||
{!! preg_replace('/<(script|style)\\b[^>]*>.*?<\\/\\1>/is', '', (string) $story->content) !!}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside class="space-y-4 lg:col-span-4">
|
||||
<div class="rounded-xl border border-gray-700 bg-gray-800/70 p-4">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-300">Moderation Actions</h3>
|
||||
<form method="POST" action="{{ route('admin.stories.approve', ['story' => $story->id]) }}" class="mt-3">
|
||||
@csrf
|
||||
<button class="w-full rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200 transition hover:scale-[1.02]">Approve & Publish</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('admin.stories.reject', ['story' => $story->id]) }}" class="mt-3 space-y-2">
|
||||
@csrf
|
||||
<label class="block text-xs uppercase tracking-wide text-gray-400">Rejection feedback</label>
|
||||
<textarea name="reason" rows="4" required class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-white" placeholder="Explain what needs to change..."></textarea>
|
||||
<button class="w-full rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-200 transition hover:scale-[1.02]">Reject Story</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-700 bg-gray-800/70 p-4">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-300">Quick Links</h3>
|
||||
<div class="mt-3 flex flex-col gap-2 text-sm">
|
||||
<a href="{{ route('admin.stories.edit', ['story' => $story->id]) }}" class="rounded-lg border border-gray-600 px-3 py-2 text-gray-200">Edit in admin form</a>
|
||||
<a href="{{ route('creator.stories.preview', ['story' => $story->id]) }}" class="rounded-lg border border-gray-600 px-3 py-2 text-gray-200">Open creator preview</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
68
resources/views/components/dashboard/filter-select.blade.php
Normal file
68
resources/views/components/dashboard/filter-select.blade.php
Normal file
@@ -0,0 +1,68 @@
|
||||
@props([
|
||||
'name',
|
||||
'value' => null,
|
||||
'options' => [],
|
||||
])
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
open: false,
|
||||
value: @js((string) ($value ?? '')),
|
||||
options: @js(collect($options)->map(fn ($option) => [
|
||||
'value' => (string) ($option['value'] ?? ''),
|
||||
'label' => (string) ($option['label'] ?? ''),
|
||||
])->values()->all()),
|
||||
labelFor(selectedValue) {
|
||||
const match = this.options.find((option) => option.value === selectedValue)
|
||||
return match ? match.label : (this.options[0]?.label ?? '')
|
||||
},
|
||||
select(nextValue) {
|
||||
this.value = nextValue
|
||||
this.open = false
|
||||
},
|
||||
}"
|
||||
class="relative"
|
||||
@click.outside="open = false"
|
||||
@keydown.escape.window="open = false"
|
||||
>
|
||||
<input type="hidden" name="{{ $name }}" x-model="value">
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="open = !open"
|
||||
class="flex w-full items-center justify-between gap-3 rounded-xl border border-white/[0.08] bg-black/20 px-4 py-3 text-left text-sm text-white transition-colors hover:border-white/[0.14] focus:border-sky-400/40 focus:outline-none"
|
||||
:aria-expanded="open.toString()"
|
||||
>
|
||||
<span class="truncate" x-text="labelFor(value)"></span>
|
||||
<i class="fa-solid fa-chevron-down text-xs text-white/40 transition-transform" :class="open ? 'rotate-180' : ''"></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-cloak
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
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-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-1"
|
||||
class="absolute left-0 right-0 z-50 mt-2 overflow-hidden rounded-2xl border border-white/[0.08] bg-slate-950/95 shadow-[0_24px_70px_rgba(0,0,0,0.45)] backdrop-blur"
|
||||
style="display: none;"
|
||||
>
|
||||
<div class="p-2">
|
||||
<template x-for="option in options" :key="option.value">
|
||||
<button
|
||||
type="button"
|
||||
@click="select(option.value)"
|
||||
class="flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left text-sm transition-colors"
|
||||
:class="value === option.value
|
||||
? 'bg-sky-400/20 text-white'
|
||||
: 'text-white/75 hover:bg-white/[0.06] hover:text-white'"
|
||||
>
|
||||
<span x-text="option.label"></span>
|
||||
<i x-show="value === option.value" class="fa-solid fa-check text-xs text-sky-200"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,27 +1,288 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto py-8 max-w-3xl">
|
||||
<h1 class="text-2xl font-semibold mb-6">My Followers</h1>
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="pt-0">
|
||||
<div class="mx-auto w-full">
|
||||
<div class="relative min-h-[calc(120vh-64px)] md:min-h-[calc(100vh-64px)]">
|
||||
<main class="w-full">
|
||||
<x-nova-page-header
|
||||
section="Dashboard"
|
||||
title="People Following Me"
|
||||
icon="fa-users"
|
||||
:breadcrumbs="collect([
|
||||
(object) ['name' => 'Dashboard', 'url' => '/dashboard'],
|
||||
(object) ['name' => 'Followers', 'url' => route('dashboard.followers')],
|
||||
])"
|
||||
description="A clearer view of who follows you, who you follow back, and who still needs a response."
|
||||
actionsClass="lg:pt-8"
|
||||
>
|
||||
<x-slot name="actions">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a href="{{ route('dashboard.following') }}"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white">
|
||||
<i class="fa-solid fa-user-check text-xs"></i>
|
||||
People I follow
|
||||
</a>
|
||||
<a href="{{ route('discover.trending') }}"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-sky-400/30 bg-sky-400/10 px-4 py-2 text-sm font-medium text-sky-100 transition-colors hover:border-sky-300/40 hover:bg-sky-400/15">
|
||||
<i class="fa-solid fa-compass text-xs"></i>
|
||||
Discover creators
|
||||
</a>
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-nova-page-header>
|
||||
|
||||
@if($followers->isEmpty())
|
||||
<p class="text-sm text-gray-500">You have no followers yet.</p>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach($followers as $f)
|
||||
<a href="{{ $f->profile_url }}" class="flex items-center gap-4 p-3 rounded-lg hover:bg-white/5 transition">
|
||||
<img src="{{ $f->avatar_url }}" alt="{{ $f->uname }}" class="w-10 h-10 rounded-full object-cover">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{{ $f->uname }}</div>
|
||||
<div class="text-xs text-gray-500">{{ $f->uploads }} uploads · followed {{ \Carbon\Carbon::parse($f->followed_at)->diffForHumans() }}</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
<section class="px-6 pb-16 pt-8 md:px-10">
|
||||
@php
|
||||
$newestFollower = $followers->getCollection()->first();
|
||||
$newestFollowerName = $newestFollower ? ($newestFollower->name ?: $newestFollower->uname) : null;
|
||||
$latestFollowedAt = $newestFollower && !empty($newestFollower->followed_at)
|
||||
? \Carbon\Carbon::parse($newestFollower->followed_at)->diffForHumans()
|
||||
: null;
|
||||
@endphp
|
||||
|
||||
<div class="mt-6">
|
||||
{{ $followers->links() }}
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
|
||||
<p class="text-xs uppercase tracking-widest text-white/35">Total followers</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{{ number_format($summary['total_followers']) }}</p>
|
||||
<p class="mt-2 text-xs text-white/40">People currently following your profile</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
|
||||
<p class="text-xs uppercase tracking-widest text-white/35">Followed back</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{{ number_format($summary['following_back']) }}</p>
|
||||
<p class="mt-2 text-xs text-white/40">Followers you also follow</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
|
||||
<p class="text-xs uppercase tracking-widest text-white/35">Not followed back</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{{ number_format($summary['not_followed']) }}</p>
|
||||
<p class="mt-2 text-xs text-white/40">Followers still waiting on your follow-back</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-sky-400/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.12),rgba(255,255,255,0.03))] p-5 shadow-[0_16px_60px_rgba(14,165,233,0.08)]">
|
||||
<p class="text-xs uppercase tracking-widest text-sky-100/60">Newest follower</p>
|
||||
<p class="mt-2 truncate text-xl font-semibold text-white">{{ $newestFollowerName ?? '—' }}</p>
|
||||
<p class="mt-2 text-xs text-sky-50/60">{{ $latestFollowedAt ?? 'No recent follower activity' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-4 shadow-[0_16px_60px_rgba(0,0,0,0.12)]">
|
||||
@php
|
||||
$sortOptions = [
|
||||
['value' => 'recent', 'label' => 'Most recent'],
|
||||
['value' => 'oldest', 'label' => 'Oldest first'],
|
||||
['value' => 'name', 'label' => 'Name A-Z'],
|
||||
['value' => 'uploads', 'label' => 'Most uploads'],
|
||||
['value' => 'followers', 'label' => 'Most followers'],
|
||||
];
|
||||
|
||||
$relationshipOptions = [
|
||||
['value' => 'all', 'label' => 'All followers'],
|
||||
['value' => 'following-back', 'label' => 'I follow back'],
|
||||
['value' => 'not-followed', 'label' => 'Not followed back'],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<form method="GET" action="{{ route('dashboard.followers') }}" class="grid gap-4 lg:grid-cols-[minmax(0,1.35fr)_220px_220px_auto] lg:items-end">
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-xs font-semibold uppercase tracking-widest text-white/35">Search follower</span>
|
||||
<div class="relative">
|
||||
<i class="fa-solid fa-magnifying-glass pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-xs text-white/30"></i>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
value="{{ $filters['q'] }}"
|
||||
placeholder="Search by username or display name"
|
||||
class="w-full rounded-xl border border-white/[0.08] bg-black/20 py-3 pl-10 pr-4 text-sm text-white placeholder:text-white/30 focus:border-sky-400/40 focus:outline-none"
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-xs font-semibold uppercase tracking-widest text-white/35">Sort by</span>
|
||||
<x-dashboard.filter-select
|
||||
name="sort"
|
||||
:value="$filters['sort']"
|
||||
:options="$sortOptions"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-xs font-semibold uppercase tracking-widest text-white/35">Relationship</span>
|
||||
<x-dashboard.filter-select
|
||||
name="relationship"
|
||||
:value="$filters['relationship']"
|
||||
:options="$relationshipOptions"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap gap-3 lg:justify-end">
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-xl border border-sky-400/30 bg-sky-400/10 px-4 py-3 text-sm font-medium text-sky-100 transition-colors hover:border-sky-300/40 hover:bg-sky-400/15">
|
||||
<i class="fa-solid fa-sliders text-xs"></i>
|
||||
Apply
|
||||
</button>
|
||||
@if($filters['q'] !== '' || $filters['sort'] !== 'recent' || $filters['relationship'] !== 'all')
|
||||
<a href="{{ route('dashboard.followers') }}" class="inline-flex items-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white">
|
||||
<i class="fa-solid fa-rotate-left text-xs"></i>
|
||||
Reset
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-white/50">{{ number_format($followers->count()) }} visible on this page</span>
|
||||
@if($filters['q'] !== '')
|
||||
<span class="inline-flex items-center rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-sky-100/80">Search: {{ $filters['q'] }}</span>
|
||||
@endif
|
||||
@if($filters['relationship'] !== 'all')
|
||||
<span class="inline-flex items-center rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-emerald-100/80">
|
||||
{{ $filters['relationship'] === 'following-back' ? 'Following back only' : 'Not followed back' }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($followers->isEmpty())
|
||||
<div class="rounded-2xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center shadow-[0_20px_80px_rgba(0,0,0,0.18)]">
|
||||
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-white/[0.08] bg-white/[0.04] text-white/60">
|
||||
<i class="fa-solid fa-users text-lg"></i>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-white">No followers match these filters</h2>
|
||||
<p class="mx-auto mt-2 max-w-xl text-sm text-white/45">
|
||||
Try resetting the filters, or discover more creators and activity to grow your audience.
|
||||
</p>
|
||||
<div class="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<a href="{{ route('dashboard.followers') }}" class="inline-flex items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white">
|
||||
<i class="fa-solid fa-rotate-left text-xs"></i>
|
||||
Reset filters
|
||||
</a>
|
||||
<a href="{{ route('discover.trending') }}" class="inline-flex items-center gap-2 rounded-lg border border-sky-400/30 bg-sky-400/10 px-4 py-2 text-sm font-medium text-sky-100 transition-colors hover:border-sky-300/40 hover:bg-sky-400/15">
|
||||
<i class="fa-solid fa-compass text-xs"></i>
|
||||
Explore creators
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="grid gap-5 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
@foreach($followers as $f)
|
||||
@php
|
||||
$displayName = $f->name ?: $f->uname;
|
||||
$profileUsername = strtolower((string) ($f->username ?? ''));
|
||||
@endphp
|
||||
<article class="group overflow-hidden rounded-2xl border border-white/[0.06] bg-[linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0.02))] shadow-[0_18px_70px_rgba(0,0,0,0.14)] transition-all hover:-translate-y-0.5 hover:border-white/[0.10] hover:shadow-[0_24px_90px_rgba(0,0,0,0.20)]">
|
||||
<div class="flex items-start justify-between gap-4 border-b border-white/[0.05] px-5 py-5">
|
||||
<a href="{{ $f->profile_url }}" class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
<img src="{{ $f->avatar_url }}"
|
||||
alt="{{ $displayName }}"
|
||||
class="h-14 w-14 flex-shrink-0 rounded-2xl object-cover ring-1 ring-white/[0.10]"
|
||||
onerror="this.src='https://files.skinbase.org/default/avatar_default.webp'">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="truncate text-base font-semibold text-white/95 group-hover:text-white">{{ $displayName }}</h2>
|
||||
<span class="inline-flex items-center rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wide text-emerald-200">
|
||||
Follows you
|
||||
</span>
|
||||
@if($f->is_following_back)
|
||||
<span class="inline-flex items-center rounded-full border border-sky-400/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wide text-sky-100">
|
||||
Mutual
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@if(!empty($f->username))
|
||||
<div class="mt-1 truncate text-xs text-white/35">{{ '@' . $f->username }}</div>
|
||||
@endif
|
||||
<div class="mt-2 text-xs text-white/45">
|
||||
Followed you {{ !empty($f->followed_at) ? \Carbon\Carbon::parse($f->followed_at)->diffForHumans() : 'recently' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="shrink-0">
|
||||
@if(!empty($profileUsername))
|
||||
<div x-data="{
|
||||
following: {{ $f->is_following_back ? 'true' : 'false' }},
|
||||
count: {{ (int) $f->followers_count }},
|
||||
loading: false,
|
||||
hovering: false,
|
||||
async toggle() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('{{ route('profile.follow', ['username' => $profileUsername]) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok) {
|
||||
this.following = d.following;
|
||||
this.count = d.follower_count;
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
}">
|
||||
<button @click="toggle"
|
||||
@mouseenter="hovering = true"
|
||||
@mouseleave="hovering = false"
|
||||
:disabled="loading"
|
||||
class="inline-flex items-center gap-2 rounded-xl border px-3.5 py-2 text-sm font-medium transition-all"
|
||||
:class="following
|
||||
? 'border-emerald-400/25 bg-emerald-400/10 text-emerald-100 hover:border-rose-400/30 hover:bg-rose-400/10 hover:text-rose-100'
|
||||
: 'border-sky-400/30 bg-sky-400/10 text-sky-100 hover:bg-sky-400/15 hover:border-sky-300/40'">
|
||||
<i class="fa-solid fa-fw text-xs"
|
||||
:class="loading ? 'fa-circle-notch fa-spin' : (following ? (hovering ? 'fa-user-minus' : 'fa-user-check') : 'fa-user-plus')"></i>
|
||||
<span x-text="following ? (hovering ? 'Unfollow' : 'Following back') : 'Follow back'"></span>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 px-5 py-4 sm:grid-cols-3">
|
||||
<div class="rounded-xl border border-white/[0.06] bg-black/10 p-3">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-white/35">Uploads</p>
|
||||
<p class="mt-1 text-lg font-semibold text-white">{{ number_format((int) $f->uploads) }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/[0.06] bg-black/10 p-3">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-white/35">Followers</p>
|
||||
<p class="mt-1 text-lg font-semibold text-white">{{ number_format((int) $f->followers_count) }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/[0.06] bg-black/10 p-3">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-white/35">Relationship</p>
|
||||
<p class="mt-1 text-sm font-semibold {{ $f->is_following_back ? 'text-emerald-200' : 'text-amber-200' }}">
|
||||
{{ $f->is_following_back ? 'Mutual follow' : 'Follower only' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 border-t border-white/[0.05] px-5 py-4 text-xs text-white/45">
|
||||
<span>
|
||||
{{ $f->is_following_back && !empty($f->followed_back_at)
|
||||
? 'You followed back ' . \Carbon\Carbon::parse($f->followed_back_at)->diffForHumans()
|
||||
: 'Not followed back yet' }}
|
||||
</span>
|
||||
<a href="{{ $f->profile_url }}" class="inline-flex items-center gap-2 font-medium text-white/70 transition-colors hover:text-white">
|
||||
View profile
|
||||
<i class="fa-solid fa-arrow-right text-[10px]"></i>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-center">
|
||||
{{ $followers->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -18,84 +18,262 @@
|
||||
actionsClass="lg:pt-8"
|
||||
>
|
||||
<x-slot name="actions">
|
||||
<a href="{{ route('discover.trending') }}"
|
||||
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors">
|
||||
<i class="fa-solid fa-compass text-xs"></i>
|
||||
Discover creators
|
||||
</a>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a href="{{ route('dashboard.followers') }}"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white">
|
||||
<i class="fa-solid fa-users text-xs"></i>
|
||||
My followers
|
||||
</a>
|
||||
<a href="{{ route('discover.trending') }}"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-sky-400/30 bg-sky-400/10 px-4 py-2 text-sm font-medium text-sky-100 transition-colors hover:border-sky-300/40 hover:bg-sky-400/15">
|
||||
<i class="fa-solid fa-compass text-xs"></i>
|
||||
Discover creators
|
||||
</a>
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-nova-page-header>
|
||||
|
||||
<section class="px-6 pb-16 pt-8 md:px-10">
|
||||
@php
|
||||
$firstFollow = $following->getCollection()->first();
|
||||
$latestFollowedAt = $firstFollow && !empty($firstFollow->followed_at)
|
||||
? \Carbon\Carbon::parse($firstFollow->followed_at)->diffForHumans()
|
||||
: null;
|
||||
$latestFollowedName = $firstFollow ? ($firstFollow->name ?: $firstFollow->uname) : null;
|
||||
@endphp
|
||||
|
||||
@if($following->isEmpty())
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<p class="text-white/40 text-sm">You are not following anyone yet.</p>
|
||||
<a href="{{ route('discover.trending') }}" class="mt-4 inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors">
|
||||
<i class="fa-solid fa-compass text-xs"></i>
|
||||
Start following creators
|
||||
</a>
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
|
||||
<p class="text-xs uppercase tracking-widest text-white/35">Following</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{{ number_format($summary['total_following']) }}</p>
|
||||
<p class="mt-2 text-xs text-white/40">People you currently follow</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
|
||||
<p class="text-xs uppercase tracking-widest text-white/35">Mutual follows</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{{ number_format($summary['mutual']) }}</p>
|
||||
<p class="mt-2 text-xs text-white/40">People who follow you back</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
|
||||
<p class="text-xs uppercase tracking-widest text-white/35">One-way follows</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{{ number_format($summary['one_way']) }}</p>
|
||||
<p class="mt-2 text-xs text-white/40">People you follow who do not follow back</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-sky-400/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.12),rgba(255,255,255,0.03))] p-5 shadow-[0_16px_60px_rgba(14,165,233,0.08)]">
|
||||
<p class="text-xs uppercase tracking-widest text-sky-100/60">Latest followed</p>
|
||||
<p class="mt-2 truncate text-xl font-semibold text-white">{{ $latestFollowedName ?? '—' }}</p>
|
||||
<p class="mt-2 text-xs text-sky-50/60">{{ $latestFollowedAt ?? 'No recent follow activity' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-4 shadow-[0_16px_60px_rgba(0,0,0,0.12)]">
|
||||
@php
|
||||
$firstFollow = $following->getCollection()->first();
|
||||
$latestFollowedAt = $firstFollow && !empty($firstFollow->followed_at)
|
||||
? \Carbon\Carbon::parse($firstFollow->followed_at)->diffForHumans()
|
||||
: null;
|
||||
$sortOptions = [
|
||||
['value' => 'recent', 'label' => 'Most recent'],
|
||||
['value' => 'oldest', 'label' => 'Oldest first'],
|
||||
['value' => 'name', 'label' => 'Name A-Z'],
|
||||
['value' => 'uploads', 'label' => 'Most uploads'],
|
||||
['value' => 'followers', 'label' => 'Most followers'],
|
||||
];
|
||||
|
||||
$relationshipOptions = [
|
||||
['value' => 'all', 'label' => 'Everyone I follow'],
|
||||
['value' => 'mutual', 'label' => 'Mutual follows'],
|
||||
['value' => 'one-way', 'label' => 'Not following me back'],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div class="rounded-xl border border-white/[0.08] bg-white/[0.03] p-4">
|
||||
<p class="text-xs uppercase tracking-widest text-white/35">Following</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($following->total()) }}</p>
|
||||
<form method="GET" action="{{ route('dashboard.following') }}" class="grid gap-4 lg:grid-cols-[minmax(0,1.35fr)_220px_220px_auto] lg:items-end">
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-xs font-semibold uppercase tracking-widest text-white/35">Search creator</span>
|
||||
<div class="relative">
|
||||
<i class="fa-solid fa-magnifying-glass pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-xs text-white/30"></i>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
value="{{ $filters['q'] }}"
|
||||
placeholder="Search by username or display name"
|
||||
class="w-full rounded-xl border border-white/[0.08] bg-black/20 py-3 pl-10 pr-4 text-sm text-white placeholder:text-white/30 focus:border-sky-400/40 focus:outline-none"
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-xs font-semibold uppercase tracking-widest text-white/35">Sort by</span>
|
||||
<x-dashboard.filter-select
|
||||
name="sort"
|
||||
:value="$filters['sort']"
|
||||
:options="$sortOptions"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-xs font-semibold uppercase tracking-widest text-white/35">Relationship</span>
|
||||
<x-dashboard.filter-select
|
||||
name="relationship"
|
||||
:value="$filters['relationship']"
|
||||
:options="$relationshipOptions"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap gap-3 lg:justify-end">
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-xl border border-sky-400/30 bg-sky-400/10 px-4 py-3 text-sm font-medium text-sky-100 transition-colors hover:border-sky-300/40 hover:bg-sky-400/15">
|
||||
<i class="fa-solid fa-sliders text-xs"></i>
|
||||
Apply
|
||||
</button>
|
||||
@if($filters['q'] !== '' || $filters['sort'] !== 'recent' || $filters['relationship'] !== 'all')
|
||||
<a href="{{ route('dashboard.following') }}" class="inline-flex items-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white">
|
||||
<i class="fa-solid fa-rotate-left text-xs"></i>
|
||||
Reset
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/[0.08] bg-white/[0.03] p-4">
|
||||
<p class="text-xs uppercase tracking-widest text-white/35">On this page</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($following->count()) }}</p>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-white/50">{{ number_format($following->count()) }} visible on this page</span>
|
||||
@if($filters['q'] !== '')
|
||||
<span class="inline-flex items-center rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-sky-100/80">Search: {{ $filters['q'] }}</span>
|
||||
@endif
|
||||
@if($filters['relationship'] !== 'all')
|
||||
<span class="inline-flex items-center rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-emerald-100/80">
|
||||
{{ $filters['relationship'] === 'mutual' ? 'Mutual follows only' : 'Not following you back' }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($following->isEmpty())
|
||||
<div class="rounded-2xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center shadow-[0_20px_80px_rgba(0,0,0,0.18)]">
|
||||
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-white/[0.08] bg-white/[0.04] text-white/60">
|
||||
<i class="fa-solid fa-user-group text-lg"></i>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/[0.08] bg-white/[0.03] p-4 sm:col-span-2 xl:col-span-1">
|
||||
<p class="text-xs uppercase tracking-widest text-white/35">Last followed</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-white">{{ $latestFollowedAt ?? '—' }}</p>
|
||||
<h2 class="text-xl font-semibold text-white">No followed creators match these filters</h2>
|
||||
<p class="mx-auto mt-2 max-w-xl text-sm text-white/45">
|
||||
Try resetting the filters, or discover more creators to build a stronger network.
|
||||
</p>
|
||||
<div class="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<a href="{{ route('dashboard.following') }}" class="inline-flex items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white">
|
||||
<i class="fa-solid fa-rotate-left text-xs"></i>
|
||||
Reset filters
|
||||
</a>
|
||||
<a href="{{ route('discover.trending') }}" class="inline-flex items-center gap-2 rounded-lg border border-sky-400/30 bg-sky-400/10 px-4 py-2 text-sm font-medium text-sky-100 transition-colors hover:border-sky-300/40 hover:bg-sky-400/15">
|
||||
<i class="fa-solid fa-compass text-xs"></i>
|
||||
Discover creators
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/[0.06] overflow-hidden">
|
||||
<div class="grid grid-cols-[1fr_auto_auto] items-center gap-4 px-5 py-3 bg-white/[0.03] border-b border-white/[0.06]">
|
||||
<span class="text-xs font-semibold uppercase tracking-widest text-white/30">Creator</span>
|
||||
<span class="hidden sm:block text-xs font-semibold uppercase tracking-widest text-white/30 text-right">Stats</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-widest text-white/30 text-right">Followed</span>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-white/[0.04]">
|
||||
@foreach($following as $f)
|
||||
@php
|
||||
$displayName = $f->name ?: $f->uname;
|
||||
@endphp
|
||||
<a href="{{ $f->profile_url }}"
|
||||
class="grid grid-cols-[1fr_auto_auto] items-center gap-4 px-5 py-4 hover:bg-white/[0.03] transition-colors">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<img src="{{ $f->avatar_url }}"
|
||||
alt="{{ $displayName }}"
|
||||
class="w-11 h-11 rounded-full object-cover flex-shrink-0 ring-1 ring-white/[0.10]"
|
||||
onerror="this.src='https://files.skinbase.org/default/avatar_default.webp'">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold text-white/90">{{ $displayName }}</div>
|
||||
@if(!empty($f->username))
|
||||
<div class="truncate text-xs text-white/35">{{ '@' . $f->username }}</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="grid gap-5 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
@foreach($following as $f)
|
||||
@php
|
||||
$displayName = $f->name ?: $f->uname;
|
||||
$profileUsername = strtolower((string) ($f->username ?? ''));
|
||||
@endphp
|
||||
<article
|
||||
x-data="{
|
||||
following: true,
|
||||
count: {{ (int) $f->followers_count }},
|
||||
loading: false,
|
||||
hovering: false,
|
||||
async toggle() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('{{ route('profile.follow', ['username' => $profileUsername]) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok) {
|
||||
this.following = d.following;
|
||||
this.count = d.follower_count;
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
}"
|
||||
:class="following ? 'opacity-100' : 'opacity-50'"
|
||||
class="group overflow-hidden rounded-2xl border border-white/[0.06] bg-[linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0.02))] shadow-[0_18px_70px_rgba(0,0,0,0.14)] transition-all hover:-translate-y-0.5 hover:border-white/[0.10] hover:shadow-[0_24px_90px_rgba(0,0,0,0.20)]">
|
||||
<div class="flex items-start justify-between gap-4 border-b border-white/[0.05] px-5 py-5">
|
||||
<a href="{{ $f->profile_url }}" class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
<img src="{{ $f->avatar_url }}"
|
||||
alt="{{ $displayName }}"
|
||||
class="h-14 w-14 flex-shrink-0 rounded-2xl object-cover ring-1 ring-white/[0.10]"
|
||||
onerror="this.src='https://files.skinbase.org/default/avatar_default.webp'">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="truncate text-base font-semibold text-white/95 group-hover:text-white">{{ $displayName }}</h2>
|
||||
<span class="inline-flex items-center rounded-full border border-sky-400/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wide text-sky-100">
|
||||
You follow
|
||||
</span>
|
||||
@if($f->follows_you)
|
||||
<span class="inline-flex items-center rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wide text-emerald-200">
|
||||
Mutual
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@if(!empty($f->username))
|
||||
<div class="mt-1 truncate text-xs text-white/35">{{ '@' . $f->username }}</div>
|
||||
@endif
|
||||
<div class="mt-2 text-xs text-white/45">
|
||||
You followed {{ !empty($f->followed_at) ? \Carbon\Carbon::parse($f->followed_at)->diffForHumans() : 'recently' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="hidden sm:block text-right text-xs text-white/55">
|
||||
{{ number_format((int) $f->uploads) }} uploads · {{ number_format((int) $f->followers_count) }} followers
|
||||
<div class="shrink-0">
|
||||
@if(!empty($profileUsername))
|
||||
<div>
|
||||
<button @click="toggle"
|
||||
@mouseenter="hovering = true"
|
||||
@mouseleave="hovering = false"
|
||||
:disabled="loading"
|
||||
class="inline-flex items-center gap-2 rounded-xl border border-emerald-400/25 bg-emerald-400/10 px-3.5 py-2 text-sm font-medium text-emerald-100 transition-all hover:border-rose-400/30 hover:bg-rose-400/10 hover:text-rose-100"
|
||||
:class="!following ? 'border-white/[0.08] bg-white/[0.04] text-white/60' : ''">
|
||||
<i class="fa-solid fa-fw text-xs"
|
||||
:class="loading ? 'fa-circle-notch fa-spin' : (following ? (hovering ? 'fa-user-minus' : 'fa-user-check') : 'fa-user-plus')"></i>
|
||||
<span x-text="following ? (hovering ? 'Unfollow' : 'Following') : 'Follow back'"></span>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right text-xs text-white/45 whitespace-nowrap">
|
||||
{{ !empty($f->followed_at) ? \Carbon\Carbon::parse($f->followed_at)->diffForHumans() : '—' }}
|
||||
<div class="grid gap-3 px-5 py-4 sm:grid-cols-3">
|
||||
<div class="rounded-xl border border-white/[0.06] bg-black/10 p-3">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-white/35">Uploads</p>
|
||||
<p class="mt-1 text-lg font-semibold text-white">{{ number_format((int) $f->uploads) }}</p>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/[0.06] bg-black/10 p-3">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-white/35">Followers</p>
|
||||
<p class="mt-1 text-lg font-semibold text-white" x-text="typeof count !== 'undefined' ? Number(count).toLocaleString() : '{{ number_format((int) $f->followers_count) }}'"></p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/[0.06] bg-black/10 p-3">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-white/35">Relationship</p>
|
||||
<p class="mt-1 text-sm font-semibold {{ $f->follows_you ? 'text-emerald-200' : 'text-amber-200' }}">
|
||||
{{ $f->follows_you ? 'Mutual follow' : 'You follow them' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 border-t border-white/[0.05] px-5 py-4 text-xs text-white/45">
|
||||
<span>
|
||||
{{ $f->follows_you && !empty($f->follows_you_at)
|
||||
? 'They followed you ' . \Carbon\Carbon::parse($f->follows_you_at)->diffForHumans()
|
||||
: 'They do not follow you back yet' }}
|
||||
</span>
|
||||
<a href="{{ $f->profile_url }}" class="inline-flex items-center gap-2 font-medium text-white/70 transition-colors hover:text-white">
|
||||
View profile
|
||||
<i class="fa-solid fa-arrow-right text-[10px]"></i>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-center">
|
||||
|
||||
Reference in New Issue
Block a user