feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile

Forum:
- TipTap WYSIWYG editor with full toolbar
- @emoji-mart/react emoji picker (consistent with tweets)
- @mention autocomplete with user search API
- Fix PHP 8.4 parse errors in Blade templates
- Fix thread data display (paginator items)
- Align forum page widths to max-w-5xl

Discover:
- Extract shared _nav.blade.php partial
- Add missing nav links to for-you page
- Add Following link for authenticated users

Feed/Posts:
- Post model, controllers, policies, migrations
- Feed page components (PostComposer, FeedCard, etc)
- Post reactions, comments, saves, reports, sharing
- Scheduled publishing support
- Link preview controller

Profile:
- Profile page components (ProfileHero, ProfileTabs)
- Profile API controller

Uploads:
- Upload wizard enhancements
- Scheduled publish picker
- Studio status bar and readiness checklist
This commit is contained in:
2026-03-03 09:48:31 +01:00
parent 1266f81d35
commit dc51d65440
178 changed files with 14308 additions and 665 deletions

View File

@@ -1,49 +1,103 @@
@extends('layouts.nova')
@php($gridV2 = request()->query('grid') === 'v2')
@php
$galleryItems = collect($artworks->items())->map(fn ($art) => [
'id' => $art->id ?? null,
'name' => $art->name ?? $art->title ?? null,
'title' => $art->title ?? $art->name ?? null,
'thumb' => $art->thumb ?? $art->thumb_url ?? null,
'thumb_url' => $art->thumb_url ?? $art->thumb ?? null,
'slug' => $art->slug ?? '',
'author' => $art->author ?? '',
'uname' => $art->uname ?? $art->author ?? '',
'username' => $art->username ?? '',
'avatar_url' => $art->avatar_url ?? null,
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'width' => $art->width ?? null,
'height' => $art->height ?? null,
'likes' => $art->likes ?? 0,
'comments_count' => $art->comments_count ?? 0,
])->values();
@endphp
@push('scripts')
@vite('resources/js/entry-masonry-gallery.jsx')
@endpush
@push('head')
<style>
.nb-hero-fade {
background: linear-gradient(180deg, rgba(17,24,39,0) 0%, rgba(7,10,15,0.9) 60%, rgba(7,10,15,1) 100%);
}
.nb-hero-gradient {
background: linear-gradient(135deg, rgba(224,122,33,0.08) 0%, rgba(15,23,36,0) 50%, rgba(21,36,58,0.4) 100%);
animation: nb-hero-shimmer 8s ease-in-out infinite alternate;
}
@keyframes nb-hero-shimmer {
0% { opacity: 0.6; }
100% { opacity: 1; }
}
</style>
@endpush
@section('content')
<div class="container mx-auto py-8">
<h1 class="text-2xl font-semibold mb-4">Favourites</h1>
<div class="container-fluid legacy-page">
<div class="pt-0">
<div class="mx-auto w-full">
<div class="relative min-h-[calc(100vh-64px)]">
<main class="w-full">
<div class="relative overflow-hidden nb-hero-radial">
<div class="absolute inset-0 nb-hero-gradient" aria-hidden="true"></div>
<div class="absolute inset-0 opacity-20 bg-[radial-gradient(ellipse_80%_60%_at_50%_-10%,#E07A2130,transparent)]" aria-hidden="true"></div>
<div class="mb-4 flex items-center justify-between">
<div class="text-sm text-muted">Showing your favourites</div>
<div>
<form method="GET" class="inline">
<label class="text-sm mr-2">Sort</label>
<select name="sort" onchange="this.form.submit()" class="rounded bg-panel px-2 py-1 text-sm">
<option value="newest" {{ ($sort ?? 'newest') === 'newest' ? 'selected' : '' }}>Newest first</option>
<option value="oldest" {{ ($sort ?? '') === 'oldest' ? 'selected' : '' }}>Oldest first</option>
</select>
</form>
<div class="relative px-6 py-10 md:px-10 md:py-14">
<nav class="flex items-center gap-1.5 flex-wrap text-sm text-neutral-400" aria-label="Breadcrumb">
<a class="hover:text-white transition-colors" href="/browse">Gallery</a>
<span class="opacity-40" aria-hidden="true"></span>
<span class="text-white/85">Favourites</span>
</nav>
<div class="mt-4 py-5">
<h1 class="text-3xl md:text-4xl font-bold tracking-tight text-white/95 leading-tight">
My Favourites
</h1>
<p class="mt-2 text-sm leading-6 text-neutral-400 max-w-xl">
Artworks you saved, displayed in the same gallery layout as Browse.
</p>
@if($artworks->total() > 0)
<div class="mt-3 flex items-center gap-1.5 text-xs text-neutral-500">
<svg class="h-3.5 w-3.5 text-accent/70" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>{{ number_format($artworks->total()) }} artworks</span>
</div>
@endif
</div>
</div>
<div class="absolute left-0 right-0 bottom-0 h-16 nb-hero-fade pointer-events-none" aria-hidden="true"></div>
</div>
<section class="px-6 pb-10 pt-8 md:px-10">
@if($artworks->isEmpty())
<div class="rounded-xl border border-white/10 bg-white/5 p-8 text-center text-white/60">
You have no favourites yet.
</div>
@else
<div
data-react-masonry-gallery
data-artworks='@json($galleryItems)'
data-gallery-type="dashboard-favorites"
@if($artworks->nextPageUrl()) data-next-page-url="{{ $artworks->nextPageUrl() }}" @endif
data-limit="20"
class="min-h-32"
></div>
@endif
</section>
</main>
</div>
</div>
</div>
@if($artworks->isEmpty())
<p class="text-sm text-gray-500">You have no favourites yet.</p>
@else
<section data-nova-gallery data-gallery-type="dashboard-favorites">
<div class="{{ $gridV2 ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6' }}" data-gallery-grid>
@foreach($artworks as $art)
<div class="relative gallery-item">
<x-artwork-card :art="$art" />
<div class="absolute right-2 top-2 z-40">
<form method="POST" action="{{ route('dashboard.favorites.destroy', ['artwork' => $art->id]) }}" onsubmit="return confirm('Really remove from favourites?');">
@csrf
@method('DELETE')
<button type="submit" class="rounded-md border border-white/15 bg-black/60 px-2 py-1 text-xs text-red-300 hover:text-red-200">Remove</button>
</form>
</div>
</div>
@endforeach
</div>
<div class="mt-6" data-gallery-pagination>{{ $artworks->links() }}</div>
<div class="hidden" data-gallery-skeleton-template aria-hidden="true">
<x-skeleton.artwork-card />
</div>
<div class="hidden mt-8" data-gallery-skeleton></div>
</section>
@endif
</div>
@endsection

View File

@@ -0,0 +1,9 @@
@extends('layouts.nova')
@push('head')
@vite(['resources/js/feed.jsx'])
@endpush
@section('content')
@inertia
@endsection

View File

@@ -0,0 +1,9 @@
@extends('layouts.nova')
@push('head')
@vite(['resources/js/feed.jsx'])
@endpush
@section('content')
@inertia
@endsection

View File

@@ -0,0 +1,9 @@
@extends('layouts.nova')
@push('head')
@vite(['resources/js/feed.jsx'])
@endpush
@section('content')
@inertia
@endsection

View File

@@ -0,0 +1,9 @@
@extends('layouts.nova')
@push('head')
@vite(['resources/js/feed.jsx'])
@endpush
@section('content')
@inertia
@endsection

View File

@@ -0,0 +1,9 @@
@extends('layouts.nova')
@push('head')
@vite(['resources/js/feed.jsx'])
@endpush
@section('content')
@inertia
@endsection

View File

@@ -1,27 +1,19 @@
@extends('layouts.nova')
@php
$forumEditPostProps = json_encode([
'post' => ['id' => $post->id, 'content' => $post->content],
'thread' => ['id' => $thread->id, 'title' => $thread->title, 'slug' => $thread->slug],
'csrfToken' => csrf_token(),
'errors' => $errors->toArray(),
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
@endphp
@section('content')
<div class="legacy-page max-w-3xl">
<div class="mb-6">
<a href="{{ route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug]) }}" class="text-sm text-sky-300 hover:text-sky-200"> Back to thread</a>
<h1 class="mt-2 text-2xl font-semibold text-white">Edit post</h1>
</div>
<form method="POST" action="{{ route('forum.post.update', ['post' => $post->id]) }}" class="space-y-4 rounded-lg border border-white/10 bg-zinc-900/70 p-4">
@csrf
@method('PUT')
<div>
<label for="content" class="mb-1 block text-sm font-medium text-zinc-200">Content</label>
<textarea id="content" name="content" rows="10" required class="w-full rounded border border-white/10 bg-zinc-950 px-3 py-2 text-zinc-100">{{ old('content', $post->content) }}</textarea>
@error('content')
<div class="mt-1 text-xs text-red-400">{{ $message }}</div>
@enderror
</div>
<div>
<button type="submit" class="rounded bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500">Save changes</button>
</div>
</form>
</div>
<div id="forum-edit-post-root"></div>
<script type="application/json" id="forum-edit-post-props">{!! $forumEditPostProps !!}</script>
@endsection
@push('scripts')
@vite(['resources/js/entry-forum.jsx'])
@endpush

View File

@@ -1,34 +1,19 @@
@extends('layouts.nova')
@php
$forumNewThreadProps = json_encode([
'category' => ['id' => $category->id, 'name' => $category->name, 'slug' => $category->slug],
'csrfToken' => csrf_token(),
'errors' => $errors->toArray(),
'oldValues' => ['title' => old('title', ''), 'content' => old('content', '')],
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
@endphp
@section('content')
<div class="legacy-page max-w-3xl">
<div class="mb-6">
<a href="{{ route('forum.category.show', ['category' => $category->slug]) }}" class="text-sm text-sky-300 hover:text-sky-200"> Back to section</a>
<h1 class="mt-2 text-2xl font-semibold text-white">Create thread in {{ $category->name }}</h1>
</div>
<form method="POST" action="{{ route('forum.thread.store', ['category' => $category->slug]) }}" class="space-y-4 rounded-lg border border-white/10 bg-zinc-900/70 p-4">
@csrf
<div>
<label for="title" class="mb-1 block text-sm font-medium text-zinc-200">Title</label>
<input id="title" name="title" value="{{ old('title') }}" required maxlength="255" class="w-full rounded border border-white/10 bg-zinc-950 px-3 py-2 text-zinc-100" />
@error('title')
<div class="mt-1 text-xs text-red-400">{{ $message }}</div>
@enderror
</div>
<div>
<label for="content" class="mb-1 block text-sm font-medium text-zinc-200">Content</label>
<textarea id="content" name="content" rows="10" required class="w-full rounded border border-white/10 bg-zinc-950 px-3 py-2 text-zinc-100">{{ old('content') }}</textarea>
@error('content')
<div class="mt-1 text-xs text-red-400">{{ $message }}</div>
@enderror
</div>
<div>
<button type="submit" class="rounded bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500">Publish thread</button>
</div>
</form>
</div>
<div id="forum-new-thread-root"></div>
<script type="application/json" id="forum-new-thread-props">{!! $forumNewThreadProps !!}</script>
@endsection
@push('scripts')
@vite(['resources/js/entry-forum.jsx'])
@endpush

View File

@@ -1,74 +1,37 @@
@extends('layouts.nova')
@php
use Carbon\Carbon;
use Illuminate\Support\Str;
$threadsData = collect($subtopics->items())->map(fn ($sub) => [
'topic_id' => (int) ($sub->topic_id ?? $sub->id ?? 0),
'topic' => $sub->topic ?? $sub->title ?? 'Untitled',
'discuss' => $sub->discuss ?? null,
'num_posts' => (int) ($sub->num_posts ?? 0),
'uname' => $sub->uname ?? 'Unknown',
'last_update' => $sub->last_update ?? $sub->post_date ?? null,
'is_pinned' => $sub->is_pinned ?? false,
])->values();
$paginationData = (isset($subtopics) && method_exists($subtopics, 'currentPage'))
? [
'current_page' => $subtopics->currentPage(),
'last_page' => $subtopics->lastPage(),
'per_page' => $subtopics->perPage(),
'total' => $subtopics->total(),
] : null;
$forumCategoryProps = json_encode([
'category' => ['id' => $category->id ?? null, 'name' => $category->name ?? '', 'slug' => $category->slug ?? ''],
'threads' => $threadsData,
'pagination' => $paginationData,
'isAuthenticated' => auth()->check(),
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
@endphp
@section('content')
<div class="legacy-page">
<div class="mb-6">
<a href="{{ route('forum.index') }}" class="text-sm text-sky-300 hover:text-sky-200"> Back to forum</a>
<h1 class="mt-2 text-2xl font-semibold text-white">{{ $topic->topic ?? $topic->title ?? 'Topic' }}</h1>
@if (!empty($topic->discuss))
<p class="mt-1 text-sm text-zinc-300">{!! Str::limit(strip_tags((string) $topic->discuss), 220) !!}</p>
@endif
@if (isset($category) && auth()->check())
<div class="mt-3">
<a href="{{ route('forum.thread.create', ['category' => $category->slug]) }}" class="rounded bg-sky-600 px-3 py-2 text-xs font-medium text-white hover:bg-sky-500">New thread</a>
</div>
@endif
</div>
<div class="overflow-hidden rounded-lg border border-white/10 bg-zinc-900/70">
<div class="border-b border-white/10 px-4 py-3 text-sm font-semibold text-zinc-100">Threads</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-white/10 text-sm">
<thead class="bg-zinc-800/60 text-zinc-300">
<tr>
<th class="px-4 py-3 text-left font-medium">Thread</th>
<th class="px-4 py-3 text-center font-medium">Posts</th>
<th class="px-4 py-3 text-center font-medium">By</th>
<th class="px-4 py-3 text-right font-medium">Last Update</th>
</tr>
</thead>
<tbody class="divide-y divide-white/10 text-zinc-100">
@forelse (($subtopics ?? []) as $sub)
@php
$id = (int) ($sub->topic_id ?? $sub->id ?? 0);
$title = $sub->topic ?? $sub->title ?? 'Untitled';
@endphp
<tr class="hover:bg-white/5">
<td class="px-4 py-3 align-top">
<a class="font-medium text-sky-300 hover:text-sky-200" href="{{ route('forum.thread.show', ['thread' => $id, 'slug' => Str::slug($title)]) }}">{{ $title }}</a>
@if (!empty($sub->discuss))
<div class="mt-1 text-xs text-zinc-400">{!! Str::limit(strip_tags((string) $sub->discuss), 180) !!}</div>
@endif
</td>
<td class="px-4 py-3 text-center text-zinc-300">{{ $sub->num_posts ?? 0 }}</td>
<td class="px-4 py-3 text-center text-zinc-300">{{ $sub->uname ?? 'Unknown' }}</td>
<td class="px-4 py-3 text-right text-zinc-400">
@if (!empty($sub->last_update))
{{ Carbon::parse($sub->last_update)->format('d.m.Y H:i') }}
@elseif (!empty($sub->post_date))
{{ Carbon::parse($sub->post_date)->format('d.m.Y H:i') }}
@else
-
@endif
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-6 text-center text-zinc-400">No threads in this section yet.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@if (isset($subtopics) && method_exists($subtopics, 'links'))
<div class="mt-4">{{ $subtopics->withQueryString()->links() }}</div>
@endif
</div>
<div id="forum-category-root"></div>
<script type="application/json" id="forum-category-props">{!! $forumCategoryProps !!}</script>
@endsection
@push('scripts')
@vite(['resources/js/entry-forum.jsx'])
@endpush

View File

@@ -1,24 +1,16 @@
@extends('layouts.nova')
@section('content')
<main class="min-h-screen bg-slate-950 px-4 py-10" aria-labelledby="forum-page-title">
<div class="mx-auto max-w-7xl">
<header class="mb-8">
<h1 id="forum-page-title" class="text-3xl font-semibold text-white">Forum</h1>
<p class="mt-2 text-sm text-white/60">Browse forum sections and latest activity.</p>
</header>
@php
$forumIndexProps = json_encode([
'categories' => $categories ?? [],
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
@endphp
@if (($categories ?? collect())->isEmpty())
<div class="rounded-xl border border-white/10 bg-slate-900/60 p-8 text-center text-white/70">
No forum categories available yet.
</div>
@else
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3" role="list" aria-label="Forum categories">
@foreach ($categories as $category)
<x-forum.category-card :category="$category" />
@endforeach
</div>
@endif
</div>
</main>
@section('content')
<div id="forum-index-root"></div>
<script type="application/json" id="forum-index-props">{!! $forumIndexProps !!}</script>
@endsection
@push('scripts')
@vite(['resources/js/entry-forum.jsx'])
@endpush

View File

@@ -1,124 +1,85 @@
@extends('layouts.nova')
@php
use App\Support\ForumPostContent;
use App\Support\AvatarUrl;
use Illuminate\Support\Facades\Gate;
$filesBaseUrl = rtrim((string) config('cdn.files_url', ''), '/');
$serializePost = function ($post) use ($filesBaseUrl) {
$user = $post->user ?? null;
return [
'id' => $post->id,
'user_id' => $post->user_id,
'content' => $post->content,
'rendered_content' => ForumPostContent::render($post->content),
'created_at' => $post->created_at?->toIso8601String(),
'edited_at' => $post->edited_at?->toIso8601String(),
'is_edited' => (bool) $post->is_edited,
'can_edit' => auth()->check() && (
(int) $post->user_id === (int) auth()->id() || Gate::allows('moderate-forum')
),
'current_user_id' => auth()->id(),
'user' => $user ? [
'id' => $user->id,
'name' => $user->name,
'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash ?? null),
'role' => $user->role ?? 'member',
] : null,
'attachments' => collect($post->attachments ?? [])->map(fn ($a) => [
'id' => $a->id,
'mime_type' => $a->mime_type,
'url' => $filesBaseUrl !== '' ? $filesBaseUrl . '/' . ltrim($a->file_path, '/') : '/' . ltrim($a->file_path, '/'),
'file_size' => $a->file_size,
'width' => $a->width,
'height' => $a->height,
])->values()->all(),
];
};
$serializedOp = isset($opPost) && $opPost ? $serializePost($opPost) : null;
$serializedPosts = collect($posts->items())->map($serializePost)->values()->all();
$paginationData = [
'current_page' => $posts->currentPage(),
'last_page' => $posts->lastPage(),
'per_page' => $posts->perPage(),
'total' => $posts->total(),
];
@endphp
@section('content')
<main class="min-h-screen bg-slate-950 px-4 py-8" aria-labelledby="thread-title">
<div class="mx-auto max-w-5xl space-y-5">
<x-forum.thread.breadcrumbs :thread="$thread" :category="$category" />
@if (session('status'))
<div class="rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-300">
{{ session('status') }}
</div>
@endif
<section class="rounded-xl border border-white/5 bg-slate-900/70 p-5 backdrop-blur">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 id="thread-title" class="text-2xl font-semibold text-white">{{ $thread->title }}</h1>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-zinc-400">
<span>By {{ $author->name ?? 'Unknown' }}</span>
<span aria-hidden="true"></span>
<time datetime="{{ optional($thread->created_at)?->toIso8601String() }}">{{ optional($thread->created_at)?->format('d.m.Y H:i') }}</time>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="rounded-full bg-sky-500/15 px-2.5 py-1 text-sky-300">{{ number_format((int) ($thread->views ?? 0)) }} views</span>
<span class="rounded-full bg-cyan-500/15 px-2.5 py-1 text-cyan-300">{{ number_format((int) ($reply_count ?? 0)) }} replies</span>
@if ($thread->is_pinned)
<span class="rounded-full bg-amber-500/15 px-2.5 py-1 text-amber-300">Pinned</span>
@endif
@if ($thread->is_locked)
<span class="rounded-full bg-red-500/15 px-2.5 py-1 text-red-300">Locked</span>
@endif
</div>
</div>
@can('moderate-forum')
<div class="mt-4 flex flex-wrap items-center gap-2 border-t border-white/10 pt-3 text-xs">
@if ($thread->is_locked)
<form method="POST" action="{{ route('forum.thread.unlock', ['thread' => $thread->id]) }}">
@csrf
<button type="submit" class="rounded-md bg-red-500/15 px-2.5 py-1 text-red-300 hover:bg-red-500/25">Unlock thread</button>
</form>
@else
<form method="POST" action="{{ route('forum.thread.lock', ['thread' => $thread->id]) }}">
@csrf
<button type="submit" class="rounded-md bg-red-500/15 px-2.5 py-1 text-red-300 hover:bg-red-500/25">Lock thread</button>
</form>
@endif
@if ($thread->is_pinned)
<form method="POST" action="{{ route('forum.thread.unpin', ['thread' => $thread->id]) }}">
@csrf
<button type="submit" class="rounded-md bg-amber-500/15 px-2.5 py-1 text-amber-300 hover:bg-amber-500/25">Unpin thread</button>
</form>
@else
<form method="POST" action="{{ route('forum.thread.pin', ['thread' => $thread->id]) }}">
@csrf
<button type="submit" class="rounded-md bg-amber-500/15 px-2.5 py-1 text-amber-300 hover:bg-amber-500/25">Pin thread</button>
</form>
@endif
</div>
@endcan
</section>
@if (isset($opPost) && $opPost)
<x-forum.thread.post-card :post="$opPost" :thread="$thread" :is-op="true" />
@endif
<section class="space-y-4" aria-label="Replies">
@forelse ($posts as $post)
<x-forum.thread.post-card :post="$post" :thread="$thread" />
@empty
<div class="rounded-xl border border-white/10 bg-slate-900/60 px-4 py-6 text-center text-zinc-400">
No replies yet.
</div>
@endforelse
</section>
@if (method_exists($posts, 'links'))
<div class="sticky bottom-3 z-10 rounded-xl border border-white/10 bg-slate-900/80 p-2 backdrop-blur supports-[backdrop-filter]:bg-slate-900/70">
{{ $posts->withQueryString()->links() }}
</div>
@endif
@auth
@if (!$thread->is_locked)
<form method="POST" action="{{ route('forum.thread.reply', ['thread' => $thread->id]) }}" class="space-y-3 rounded-xl border border-white/5 bg-slate-900/70 p-4 backdrop-blur">
@csrf
<div class="flex items-center justify-between">
<label for="reply-content" class="text-sm font-medium text-zinc-200">Reply</label>
<span class="text-xs text-zinc-500">Minimum 2 characters</span>
</div>
<div class="rounded-lg border border-white/10 bg-slate-950 p-2">
<div class="mb-2 flex items-center gap-2 text-xs">
<button type="button" class="rounded bg-slate-800 px-2 py-1 text-zinc-200" aria-pressed="true">Write</button>
<span class="rounded bg-slate-900 px-2 py-1 text-zinc-500">Preview (coming soon)</span>
</div>
<textarea id="reply-content" name="content" rows="6" required minlength="2" maxlength="10000" class="w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-zinc-100 placeholder-zinc-500 focus:border-cyan-400 focus:outline-none focus:ring-1 focus:ring-cyan-400">{{ $reply_prefill ?? old('content') }}</textarea>
</div>
@error('content')
<p class="text-xs text-red-400">{{ $message }}</p>
@enderror
@if (!empty($quoted_post))
<p class="text-xs text-cyan-300">Replying with quote from {{ data_get($quoted_post, 'user.name', 'Anonymous') }}.</p>
@endif
<div class="flex items-center justify-between">
<p class="text-xs text-zinc-500">Markdown/BBCode + attachments will be enabled in next pass</p>
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400">Post reply</button>
</div>
</form>
@else
<div class="rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-3 text-sm text-red-300">
This thread is locked. Replies are disabled.
</div>
@endif
@else
<div class="rounded-xl border border-white/10 bg-slate-900/60 px-4 py-4 text-sm text-zinc-300">
<a href="{{ route('login') }}" class="text-sky-300 hover:text-sky-200">Sign in</a> to post a reply.
</div>
@endauth
</div>
</main>
<div id="forum-thread-root"></div>
@php
$forumThreadProps = json_encode([
'thread' => [
'id' => $thread->id,
'title' => $thread->title,
'slug' => $thread->slug,
'views' => (int) ($thread->views ?? 0),
'is_pinned' => (bool) $thread->is_pinned,
'is_locked' => (bool) $thread->is_locked,
'created_at' => $thread->created_at?->toIso8601String(),
],
'category' => ['id' => $category->id ?? null, 'name' => $category->name ?? '', 'slug' => $category->slug ?? ''],
'author' => ['name' => $author->name ?? 'Unknown'],
'opPost' => $serializedOp,
'posts' => $serializedPosts,
'pagination' => $paginationData,
'replyCount' => (int) ($reply_count ?? 0),
'sort' => $sort ?? 'asc',
'quotedPost' => $quoted_post ? ['user' => ['name' => data_get($quoted_post, 'user.name', 'Anonymous')]] : null,
'replyPrefill' => $reply_prefill ?? '',
'isAuthenticated' => auth()->check(),
'canModerate' => Gate::allows('moderate-forum'),
'csrfToken' => csrf_token(),
'status' => session('status'),
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
@endphp
<script type="application/json" id="forum-thread-props">{!! $forumThreadProps !!}</script>
@endsection
@push('scripts')
@vite(['resources/js/entry-forum.jsx'])
@endpush

View File

@@ -76,9 +76,31 @@
@endif
</div>
{{-- Posts & Feed Settings --}}
<div class="border-t border-gray-200 pt-6">
<h3 class="text-sm font-medium text-gray-900">{{ __('Posts & Feed') }}</h3>
<div class="mt-4 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-700">{{ __('Auto-post new uploads') }}</p>
<p class="text-xs text-gray-500 mt-0.5">{{ __('Automatically create a feed post when you publish new artwork.') }}</p>
</div>
<div class="flex items-center gap-2">
<input type="hidden" name="auto_post_upload" value="0" />
<input
id="auto_post_upload"
type="checkbox"
name="auto_post_upload"
value="1"
{{ optional(auth()->user()->profile)->auto_post_upload ? 'checked' : '' }}
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
/>
<label for="auto_post_upload" class="text-sm text-gray-600">{{ __('Enabled') }}</label>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<x-primary-button>{{ __('Save') }}</x-primary-button>
@if (session('status') === 'profile-updated')
<p
x-data="{ show: true }"

View File

@@ -0,0 +1,20 @@
@extends('layouts.nova')
@push('head')
@vite(['resources/js/profile.jsx'])
{{-- OG image (not in nova base layout) --}}
@if(!empty($og_image))
<meta property="og:image" content="{{ $og_image }}">
@endif
<meta property="og:url" content="{{ $page_canonical ?? url()->current() }}">
<style>
/* Ensure profile tab bar does not hide behind the main navbar */
.profile-tabs-sticky {
top: var(--navbar-height, 4rem);
}
</style>
@endpush
@section('content')
@inertia
@endsection

View File

@@ -0,0 +1,38 @@
{{--
Discover section-switcher pills.
Expected variable: $section (string) the active section slug, e.g. 'trending', 'for-you'
Expected variable: $isAuthenticated (bool, optional) whether the user is logged in
--}}
@php
$active = $section ?? '';
$isAuth = $isAuthenticated ?? auth()->check();
$sections = collect([
'for-you' => ['label' => 'For You', 'icon' => 'fa-wand-magic-sparkles', 'auth' => true, 'activeClass' => 'bg-yellow-500/20 text-yellow-300 border border-yellow-400/20'],
'following' => ['label' => 'Following', 'icon' => 'fa-user-group', 'auth' => true, 'activeClass' => 'bg-sky-600 text-white'],
'trending' => ['label' => 'Trending', 'icon' => 'fa-fire', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'],
'rising' => ['label' => 'Rising', 'icon' => 'fa-rocket', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'],
'fresh' => ['label' => 'Fresh', 'icon' => 'fa-bolt', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'],
'top-rated' => ['label' => 'Top Rated', 'icon' => 'fa-medal', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'],
'most-downloaded' => ['label' => 'Most Downloaded', 'icon' => 'fa-download', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'],
'on-this-day' => ['label' => 'On This Day', 'icon' => 'fa-calendar-day', 'auth' => false, 'activeClass' => 'bg-sky-600 text-white'],
]);
@endphp
<div class="flex flex-wrap items-center gap-2 text-sm">
@foreach($sections as $slug => $meta)
@if($meta['auth'] && !$isAuth)
@continue
@endif
<a href="{{ route('discover.' . $slug) }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
{{ $active === $slug
? $meta['activeClass']
: 'bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white' }}">
<i class="fa-solid {{ $meta['icon'] }} text-xs {{ $active === $slug && $slug === 'for-you' ? '' : '' }}"></i>
{{ $meta['label'] }}
</a>
@endforeach
</div>

View File

@@ -15,28 +15,7 @@
</div>
{{-- Section switcher pills --}}
<div class="flex flex-wrap items-center gap-2 text-sm">
<a href="{{ route('discover.for-you') }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-yellow-500/20 text-yellow-300 border border-yellow-400/20">
<i class="fa-solid fa-wand-magic-sparkles text-xs"></i>
For You
</a>
@php
$sections = [
'trending' => ['label' => 'Trending', 'icon' => 'fa-fire'],
'fresh' => ['label' => 'Fresh', 'icon' => 'fa-bolt'],
'top-rated' => ['label' => 'Top Rated', 'icon' => 'fa-medal'],
'most-downloaded' => ['label' => 'Most Downloaded', 'icon' => 'fa-download'],
];
@endphp
@foreach($sections as $slug => $meta)
<a href="{{ route('discover.' . $slug) }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white">
<i class="fa-solid {{ $meta['icon'] }} text-xs"></i>
{{ $meta['label'] }}
</a>
@endforeach
</div>
@include('web.discover._nav', ['section' => 'for-you'])
</div>
</div>

View File

@@ -17,34 +17,7 @@
</div>
{{-- Section switcher pills --}}
<div class="flex flex-wrap items-center gap-2 text-sm">
@auth
<a href="{{ route('discover.for-you') }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white">
<i class="fa-solid fa-wand-magic-sparkles text-xs text-yellow-400/80"></i>
For You
</a>
@endauth
@php
$sections = [
'trending' => ['label' => 'Trending', 'icon' => 'fa-fire'],
'rising' => ['label' => 'Rising', 'icon' => 'fa-rocket'],
'fresh' => ['label' => 'Fresh', 'icon' => 'fa-bolt'],
'top-rated' => ['label' => 'Top Rated', 'icon' => 'fa-medal'],
'most-downloaded' => ['label' => 'Most Downloaded', 'icon' => 'fa-download'],
'on-this-day' => ['label' => 'On This Day', 'icon' => 'fa-calendar-day'],
];
$active = $section ?? '';
@endphp
@foreach($sections as $slug => $meta)
<a href="{{ route('discover.' . $slug) }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
{{ $active === $slug ? 'bg-sky-600 text-white' : 'bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white' }}">
<i class="fa-solid {{ $meta['icon'] }} text-xs"></i>
{{ $meta['label'] }}
</a>
@endforeach
</div>
@include('web.discover._nav', ['section' => $section ?? ''])
</div>
</div>