Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +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 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

@@ -0,0 +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 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

@@ -0,0 +1,37 @@
@extends('layouts.nova')
@php
$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 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

@@ -0,0 +1,50 @@
@php
$name = data_get($category, 'name', 'Untitled');
$slug = data_get($category, 'slug');
$categoryUrl = !empty($slug) ? route('forum.category.show', ['category' => $slug]) : '#';
$threads = (int) data_get($category, 'thread_count', 0);
$posts = (int) data_get($category, 'post_count', 0);
$lastActivity = data_get($category, 'last_activity_at');
$preview = data_get($category, 'preview_image', config('forum.preview_images.default'));
@endphp
<a
href="{{ $categoryUrl }}"
aria-label="Open {{ $name }} category"
role="listitem"
class="group relative block overflow-hidden rounded-xl border border-white/5 bg-slate-900/80 shadow-xl backdrop-blur transition-all duration-300 hover:shadow-cyan-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
>
<div class="relative aspect-[4/3] sm:aspect-[16/9]">
<img
src="{{ $preview }}"
alt="{{ $name }} preview"
loading="lazy"
decoding="async"
class="h-full w-full object-cover object-center transition-transform duration-300 group-hover:scale-[1.02]"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent"></div>
<div class="absolute inset-x-0 bottom-0 p-4">
<div class="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-400/15 text-cyan-300">
<i class="fa-solid fa-comments" aria-hidden="true"></i>
</div>
<h3 class="text-lg font-semibold text-white">{{ $name }}</h3>
<p class="mt-1 text-xs text-white/60">
Last activity:
@if ($lastActivity)
<time datetime="{{ \Illuminate\Support\Carbon::parse($lastActivity)->toIso8601String() }}">
{{ \Illuminate\Support\Carbon::parse($lastActivity)->diffForHumans() }}
</time>
@else
No activity yet
@endif
</p>
<div class="mt-3 flex items-center gap-4 text-sm text-cyan-300">
<span>{{ number_format($posts) }} posts</span>
<span>{{ number_format($threads) }} topics</span>
</div>
</div>
</div>
</a>

View File

@@ -0,0 +1,16 @@
@extends('layouts.nova')
@php
$forumIndexProps = json_encode([
'categories' => $categories ?? [],
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
@endphp
@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

@@ -0,0 +1,84 @@
@php
$attachments = collect($attachments ?? []);
$filesBaseUrl = rtrim((string) config('cdn.files_url', ''), '/');
$toUrl = function (?string $path) use ($filesBaseUrl): string {
$cleanPath = ltrim((string) $path, '/');
return $filesBaseUrl !== '' ? ($filesBaseUrl . '/' . $cleanPath) : ('/' . $cleanPath);
};
$formatBytes = function ($bytes): string {
$size = max((int) $bytes, 0);
if ($size < 1024) {
return $size . ' B';
}
$units = ['KB', 'MB', 'GB'];
$value = $size / 1024;
$unitIndex = 0;
while ($value >= 1024 && $unitIndex < count($units) - 1) {
$value /= 1024;
$unitIndex++;
}
return number_format($value, 1) . ' ' . $units[$unitIndex];
};
@endphp
@if ($attachments->isNotEmpty())
<div class="mt-4 space-y-3 border-t border-white/10 pt-4">
<h4 class="text-xs font-semibold uppercase tracking-wide text-zinc-400">Attachments</h4>
<ul class="grid grid-cols-1 gap-3 sm:grid-cols-2">
@foreach ($attachments as $attachment)
@php
$mime = (string) ($attachment->mime_type ?? '');
$isImage = str_starts_with($mime, 'image/');
$url = $toUrl($attachment->file_path ?? '');
$modalId = 'attachment-modal-' . (string) data_get($attachment, 'id', uniqid());
@endphp
<li class="rounded-lg border border-white/10 bg-slate-900/60 p-3">
@if ($isImage)
<a href="#{{ $modalId }}" class="block overflow-hidden rounded-md border border-white/10">
<img
src="{{ $url }}"
alt="Attachment preview"
loading="lazy"
decoding="async"
class="h-36 w-full object-cover"
/>
</a>
@endif
<div class="mt-2 flex items-center justify-between gap-3 text-xs">
<span class="truncate text-zinc-300">{{ basename((string) ($attachment->file_path ?? 'file')) }}</span>
<span class="text-zinc-500">{{ $formatBytes($attachment->file_size ?? 0) }}</span>
</div>
<a href="{{ $url }}" target="_blank" rel="noopener noreferrer" class="mt-2 inline-flex text-xs font-medium text-sky-300 hover:text-sky-200">
Download
</a>
@if ($isImage)
<div id="{{ $modalId }}" class="pointer-events-none fixed inset-0 z-50 hidden bg-black/80 p-4 target:pointer-events-auto target:block" role="dialog" aria-label="Attachment preview">
<div class="mx-auto flex h-full w-full max-w-5xl items-center justify-center">
<div class="w-full overflow-hidden rounded-xl border border-white/10 bg-slate-950/95">
<div class="flex items-center justify-between border-b border-white/10 px-4 py-2">
<span class="truncate text-xs text-zinc-300">{{ basename((string) ($attachment->file_path ?? 'file')) }}</span>
<a href="#" class="text-xs text-zinc-400 hover:text-zinc-200">Close</a>
</div>
<div class="max-h-[80vh] overflow-auto p-3">
<img src="{{ $url }}" alt="Attachment full preview" class="mx-auto h-auto max-h-[72vh] w-auto max-w-full object-contain" />
</div>
<div class="border-t border-white/10 px-4 py-2 text-right">
<a href="{{ $url }}" target="_blank" rel="noopener noreferrer" class="text-xs font-medium text-sky-300 hover:text-sky-200">Open original</a>
</div>
</div>
</div>
</div>
@endif
</li>
@endforeach
</ul>
</div>
@endif

View File

@@ -0,0 +1,32 @@
@php
$user = $user ?? null;
$name = data_get($user, 'name', 'Anonymous');
$avatar = data_get($user, 'profile.avatar_url') ?? \App\Support\AvatarUrl::forUser((int) data_get($user, 'id', 0));
$role = strtolower((string) data_get($user, 'role', 'member'));
$roleLabel = match ($role) {
'admin' => 'Admin',
'moderator' => 'Moderator',
default => 'Member',
};
$roleClasses = match ($role) {
'admin' => 'bg-red-500/15 text-red-300',
'moderator' => 'bg-amber-500/15 text-amber-300',
default => 'bg-sky-500/15 text-sky-300',
};
@endphp
<div class="flex items-center gap-3">
<img
src="{{ $avatar }}"
alt="{{ $name }} avatar"
loading="lazy"
decoding="async"
class="h-10 w-10 rounded-full border border-white/10 object-cover"
/>
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-zinc-100">{{ $name }}</div>
<span class="inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium {{ $roleClasses }}">{{ $roleLabel }}</span>
</div>
</div>

View File

@@ -0,0 +1,30 @@
@php
$thread = $thread ?? null;
$category = $category ?? null;
@endphp
<nav class="text-sm text-zinc-400" aria-label="Breadcrumb" itemscope itemtype="https://schema.org/BreadcrumbList">
<ol class="flex flex-wrap items-center gap-2">
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
<a itemprop="item" href="{{ url('/') }}" class="hover:text-zinc-200"><span itemprop="name">Home</span></a>
<meta itemprop="position" content="1">
</li>
<li aria-hidden="true">/</li>
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
<a itemprop="item" href="{{ route('forum.index') }}" class="hover:text-zinc-200"><span itemprop="name">Forum</span></a>
<meta itemprop="position" content="2">
</li>
<li aria-hidden="true">/</li>
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
<a itemprop="item" href="{{ isset($category) ? route('forum.category.show', ['category' => $category->slug]) : route('forum.index') }}" class="hover:text-zinc-200">
<span itemprop="name">{{ $category->name ?? 'Category' }}</span>
</a>
<meta itemprop="position" content="3">
</li>
<li aria-hidden="true">/</li>
<li class="text-zinc-200" itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
<span itemprop="name">{{ $thread->title ?? 'Thread' }}</span>
<meta itemprop="position" content="4">
</li>
</ol>
</nav>

View File

@@ -0,0 +1,71 @@
@php
$post = $post ?? null;
$thread = $thread ?? null;
$isOp = (bool) ($isOp ?? false);
$author = data_get($post, 'user');
$postedAt = data_get($post, 'created_at');
$editedAt = data_get($post, 'edited_at');
$content = (string) data_get($post, 'content', '');
$rendered = \App\Support\ForumPostContent::render($content);
@endphp
<article class="overflow-hidden rounded-xl border border-white/5 bg-slate-900/70 backdrop-blur" id="post-{{ data_get($post, 'id') }}">
<header class="border-b border-white/10 px-4 py-3 sm:px-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<x-forum.thread.author-badge :user="$author" />
<div class="text-xs text-zinc-400">
@if ($postedAt)
<time datetime="{{ \Illuminate\Support\Carbon::parse($postedAt)->toIso8601String() }}">
{{ \Illuminate\Support\Carbon::parse($postedAt)->format('d.m.Y H:i') }}
</time>
@endif
@if ($isOp)
<span class="ml-2 rounded-full bg-cyan-500/15 px-2 py-0.5 text-[11px] font-medium text-cyan-300">OP</span>
@endif
</div>
</div>
</header>
<div class="px-4 py-4 sm:px-5">
<div class="prose prose-invert max-w-none text-sm leading-6 prose-pre:overflow-x-auto">
{!! $rendered !!}
</div>
@if (data_get($post, 'is_edited') && $editedAt)
<p class="mt-3 text-xs text-zinc-500">
Edited <time datetime="{{ \Illuminate\Support\Carbon::parse($editedAt)->toIso8601String() }}">{{ \Illuminate\Support\Carbon::parse($editedAt)->diffForHumans() }}</time>
</p>
@endif
<x-forum.thread.attachment-list :attachments="data_get($post, 'attachments', [])" />
</div>
<footer class="flex flex-wrap items-center gap-3 border-t border-white/10 px-4 py-3 text-xs text-zinc-400 sm:px-5">
<button type="button" disabled aria-disabled="true" title="Like coming soon" class="cursor-not-allowed rounded border border-white/10 px-2 py-0.5 text-zinc-500">Like</button>
@if (!empty(data_get($thread, 'id')))
<a href="{{ route('forum.thread.show', ['thread' => data_get($thread, 'id'), 'slug' => data_get($thread, 'slug'), 'quote' => data_get($post, 'id')]) }}#reply-content" class="hover:text-zinc-200">Quote</a>
@else
<a href="#post-{{ data_get($post, 'id') }}" class="hover:text-zinc-200">Quote</a>
@endif
@auth
@if ((int) data_get($post, 'user_id') !== (int) auth()->id())
<form method="POST" action="{{ route('forum.post.report', ['post' => data_get($post, 'id')]) }}" class="inline">
@csrf
<button type="submit" class="hover:text-zinc-200">Report</button>
</form>
@endif
@else
<a href="{{ route('login') }}" class="hover:text-zinc-200">Report</a>
@endauth
@auth
@if ((int) data_get($post, 'user_id') === (int) auth()->id() || Gate::allows('moderate-forum'))
<a href="{{ route('forum.post.edit', ['post' => data_get($post, 'id')]) }}" class="hover:text-zinc-200">Edit</a>
@endif
@endauth
@can('moderate-forum')
<span class="ml-auto text-amber-300">Moderation tools available</span>
@endcan
</footer>
</article>

View File

@@ -0,0 +1,98 @@
@extends('layouts.nova')
@php
use App\Support\ForumPostContent;
use App\Support\AvatarUrl;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
$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',
'level' => (int) ($user->level ?? 1),
'rank' => (string) ($user->rank ?? 'Newbie'),
] : 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();
$threadDescription = null;
$threadDescriptionSource = (string) ($serializedOp['rendered_content'] ?? $serializedOp['content'] ?? '');
if ($threadDescriptionSource !== '') {
$threadDescriptionSource = html_entity_decode($threadDescriptionSource, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$threadDescriptionSource = html_entity_decode($threadDescriptionSource, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$threadDescription = trim((string) preg_replace('/\s+/u', ' ', strip_tags($threadDescriptionSource)));
$threadDescription = Str::limit($threadDescription, 220);
}
$paginationData = [
'current_page' => $posts->currentPage(),
'last_page' => $posts->lastPage(),
'per_page' => $posts->perPage(),
'total' => $posts->total(),
];
@endphp
@section('content')
<div id="forum-thread-root"></div>
@php
$forumThreadProps = json_encode([
'thread' => [
'id' => $thread->id,
'title' => $thread->title,
'slug' => $thread->slug,
'description'=> $threadDescription,
'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