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

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\User;
use App\Services\ThumbnailPresenter;
use App\Services\ThumbnailService;
use App\Support\AvatarUrl;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* ProfileApiController
* JSON API endpoints for Profile page v2 tabs.
*/
final class ProfileApiController extends Controller
{
/**
* GET /api/profile/{username}/artworks
* Returns cursor-paginated artworks for the profile page tabs.
* Supports: sort=latest|trending|rising|views|favs, cursor=...
*/
public function artworks(Request $request, string $username): JsonResponse
{
$user = $this->resolveUser($username);
if (! $user) {
return response()->json(['error' => 'User not found'], 404);
}
$isOwner = Auth::check() && Auth::id() === $user->id;
$sort = $request->input('sort', 'latest');
$query = Artwork::with('user:id,name,username')
->where('user_id', $user->id)
->whereNull('deleted_at');
if (! $isOwner) {
$query->where('is_public', true)->where('is_approved', true)->whereNotNull('published_at');
}
$query = match ($sort) {
'trending' => $query->orderByDesc('ranking_score'),
'rising' => $query->orderByDesc('heat_score'),
'views' => $query->orderByDesc('view_count'),
'favs' => $query->orderByDesc('favourite_count'),
default => $query->orderByDesc('published_at'),
};
$perPage = 24;
$paginator = $query->cursorPaginate($perPage);
$data = collect($paginator->items())->map(function (Artwork $art) {
$present = ThumbnailPresenter::present($art, 'md');
return [
'id' => $art->id,
'name' => $art->title,
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'width' => $art->width,
'height' => $art->height,
'username' => $art->user->username ?? null,
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
'published_at' => $art->published_at,
];
})->values();
return response()->json([
'data' => $data,
'next_cursor' => $paginator->nextCursor()?->encode(),
'has_more' => $paginator->hasMorePages(),
]);
}
/**
* GET /api/profile/{username}/favourites
* Returns cursor-paginated favourites for the profile.
*/
public function favourites(Request $request, string $username): JsonResponse
{
if (! Schema::hasTable('user_favorites')) {
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
}
$user = $this->resolveUser($username);
if (! $user) {
return response()->json(['error' => 'User not found'], 404);
}
$perPage = 24;
$cursor = $request->input('cursor');
$favIds = DB::table('user_favorites as uf')
->join('artworks as a', 'a.id', '=', 'uf.artwork_id')
->where('uf.user_id', $user->id)
->whereNull('a.deleted_at')
->where('a.is_public', true)
->where('a.is_approved', true)
->orderByDesc('uf.created_at')
->offset($cursor ? (int) base64_decode($cursor) : 0)
->limit($perPage + 1)
->pluck('a.id');
$hasMore = $favIds->count() > $perPage;
$favIds = $favIds->take($perPage);
if ($favIds->isEmpty()) {
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
}
$indexed = Artwork::with('user:id,name,username')
->whereIn('id', $favIds)
->get()
->keyBy('id');
$data = $favIds->filter(fn ($id) => $indexed->has($id))->map(function ($id) use ($indexed) {
$art = $indexed[$id];
$present = ThumbnailPresenter::present($art, 'md');
return [
'id' => $art->id,
'name' => $art->title,
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'width' => $art->width,
'height' => $art->height,
'username' => $art->user->username ?? null,
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
];
})->values();
return response()->json([
'data' => $data,
'next_cursor' => null, // Simple offset pagination for now
'has_more' => $hasMore,
]);
}
/**
* GET /api/profile/{username}/stats
* Returns profile statistics.
*/
public function stats(Request $request, string $username): JsonResponse
{
$user = $this->resolveUser($username);
if (! $user) {
return response()->json(['error' => 'User not found'], 404);
}
$stats = null;
if (Schema::hasTable('user_statistics')) {
$stats = DB::table('user_statistics')->where('user_id', $user->id)->first();
}
$followerCount = 0;
if (Schema::hasTable('user_followers')) {
$followerCount = DB::table('user_followers')->where('user_id', $user->id)->count();
}
return response()->json([
'stats' => $stats,
'follower_count' => $followerCount,
]);
}
private function resolveUser(string $username): ?User
{
$normalized = UsernamePolicy::normalize($username);
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
}
}