update
This commit is contained in:
95
app/Http/Controllers/Admin/CountryAdminController.php
Normal file
95
app/Http/Controllers/Admin/CountryAdminController.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Country;
|
||||
use App\Services\Countries\CountrySyncService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Throwable;
|
||||
|
||||
final class CountryAdminController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$search = trim((string) $request->query('q', ''));
|
||||
|
||||
$countries = Country::query()
|
||||
->when($search !== '', function ($query) use ($search): void {
|
||||
$query->where(function ($countryQuery) use ($search): void {
|
||||
$countryQuery
|
||||
->where('iso2', 'like', '%'.$search.'%')
|
||||
->orWhere('iso3', 'like', '%'.$search.'%')
|
||||
->orWhere('name_common', 'like', '%'.$search.'%')
|
||||
->orWhere('name_official', 'like', '%'.$search.'%');
|
||||
});
|
||||
})
|
||||
->ordered()
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.countries.index', [
|
||||
'countries' => $countries,
|
||||
'search' => $search,
|
||||
]);
|
||||
}
|
||||
|
||||
public function sync(Request $request, CountrySyncService $countrySyncService): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$summary = $countrySyncService->sync();
|
||||
} catch (Throwable $exception) {
|
||||
return redirect()
|
||||
->route('admin.countries.index')
|
||||
->with('error', $exception->getMessage());
|
||||
}
|
||||
|
||||
$message = sprintf(
|
||||
'Countries synced from %s. Inserted %d, updated %d, skipped %d, deactivated %d.',
|
||||
(string) ($summary['source'] ?? 'unknown'),
|
||||
(int) ($summary['inserted'] ?? 0),
|
||||
(int) ($summary['updated'] ?? 0),
|
||||
(int) ($summary['skipped'] ?? 0),
|
||||
(int) ($summary['deactivated'] ?? 0),
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('admin.countries.index')
|
||||
->with('success', $message);
|
||||
}
|
||||
|
||||
public function cpMain(Request $request): View
|
||||
{
|
||||
$view = $this->index($request);
|
||||
|
||||
return view('admin.countries.cpad', $view->getData());
|
||||
}
|
||||
|
||||
public function cpSync(Request $request, CountrySyncService $countrySyncService): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$summary = $countrySyncService->sync();
|
||||
} catch (Throwable $exception) {
|
||||
return redirect()
|
||||
->route('admin.cp.countries.main')
|
||||
->with('msg_error', $exception->getMessage());
|
||||
}
|
||||
|
||||
$message = sprintf(
|
||||
'Countries synced from %s. Inserted %d, updated %d, skipped %d, deactivated %d.',
|
||||
(string) ($summary['source'] ?? 'unknown'),
|
||||
(int) ($summary['inserted'] ?? 0),
|
||||
(int) ($summary['updated'] ?? 0),
|
||||
(int) ($summary['skipped'] ?? 0),
|
||||
(int) ($summary['deactivated'] ?? 0),
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('admin.cp.countries.main')
|
||||
->with('msg_success', $message);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Models\Story;
|
||||
use App\Models\StoryTag;
|
||||
use App\Models\User;
|
||||
use App\Notifications\StoryStatusNotification;
|
||||
use App\Services\StoryPublicationService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -78,6 +79,10 @@ class StoryAdminController extends Controller
|
||||
$story->tags()->sync($validated['tags']);
|
||||
}
|
||||
|
||||
if ($validated['status'] === 'published') {
|
||||
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.stories.edit', ['story' => $story->id])
|
||||
->with('status', 'Story created.');
|
||||
}
|
||||
@@ -95,6 +100,8 @@ class StoryAdminController extends Controller
|
||||
|
||||
public function update(Request $request, Story $story): RedirectResponse
|
||||
{
|
||||
$wasPublished = $story->published_at !== null || $story->status === 'published';
|
||||
|
||||
$validated = $request->validate([
|
||||
'creator_id' => ['required', 'integer', 'exists:users,id'],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
@@ -122,6 +129,10 @@ class StoryAdminController extends Controller
|
||||
|
||||
$story->tags()->sync($validated['tags'] ?? []);
|
||||
|
||||
if (! $wasPublished && $validated['status'] === 'published') {
|
||||
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
|
||||
}
|
||||
|
||||
return back()->with('status', 'Story updated.');
|
||||
}
|
||||
|
||||
@@ -134,14 +145,11 @@ class StoryAdminController extends Controller
|
||||
|
||||
public function publish(Story $story): RedirectResponse
|
||||
{
|
||||
$story->update([
|
||||
'status' => 'published',
|
||||
app(StoryPublicationService::class)->publish($story, 'published', [
|
||||
'published_at' => $story->published_at ?? now(),
|
||||
'reviewed_at' => now(),
|
||||
]);
|
||||
|
||||
$story->creator?->notify(new StoryStatusNotification($story, 'published'));
|
||||
|
||||
return back()->with('status', 'Story published.');
|
||||
}
|
||||
|
||||
@@ -154,16 +162,13 @@ class StoryAdminController extends Controller
|
||||
|
||||
public function approve(Request $request, Story $story): RedirectResponse
|
||||
{
|
||||
$story->update([
|
||||
'status' => 'published',
|
||||
app(StoryPublicationService::class)->publish($story, 'approved', [
|
||||
'published_at' => $story->published_at ?? now(),
|
||||
'reviewed_at' => now(),
|
||||
'reviewed_by_id' => (int) $request->user()->id,
|
||||
'rejected_reason' => null,
|
||||
]);
|
||||
|
||||
$story->creator?->notify(new StoryStatusNotification($story, 'approved'));
|
||||
|
||||
return back()->with('status', 'Story approved and published.');
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMention;
|
||||
use App\Notifications\ArtworkCommentedNotification;
|
||||
use App\Notifications\ArtworkMentionedNotification;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\LegacySmileyMapper;
|
||||
use App\Support\AvatarUrl;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -113,6 +116,7 @@ class ArtworkCommentController extends Controller
|
||||
Cache::forget('comments.latest.all.page1');
|
||||
|
||||
$comment->load(['user', 'user.profile']);
|
||||
$this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null);
|
||||
|
||||
// Record activity event (fire-and-forget; never break the response)
|
||||
try {
|
||||
@@ -204,6 +208,8 @@ class ArtworkCommentController extends Controller
|
||||
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
|
||||
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
||||
'level' => (int) ($user?->level ?? 1),
|
||||
'rank' => (string) ($user?->rank ?? 'Newbie'),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -217,4 +223,48 @@ class ArtworkCommentController extends Controller
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function notifyRecipients(Artwork $artwork, ArtworkComment $comment, User $actor, ?int $parentId): void
|
||||
{
|
||||
$notifiedUserIds = [];
|
||||
$creatorId = (int) ($artwork->user_id ?? 0);
|
||||
|
||||
if ($creatorId > 0 && $creatorId !== (int) $actor->id) {
|
||||
$creator = User::query()->find($creatorId);
|
||||
if ($creator) {
|
||||
$creator->notify(new ArtworkCommentedNotification($artwork, $comment, $actor));
|
||||
$notifiedUserIds[] = (int) $creator->id;
|
||||
}
|
||||
}
|
||||
|
||||
if ($parentId) {
|
||||
$parentUserId = (int) (ArtworkComment::query()->whereKey($parentId)->value('user_id') ?? 0);
|
||||
if ($parentUserId > 0 && $parentUserId !== (int) $actor->id && ! in_array($parentUserId, $notifiedUserIds, true)) {
|
||||
$parentUser = User::query()->find($parentUserId);
|
||||
if ($parentUser) {
|
||||
$parentUser->notify(new ArtworkCommentedNotification($artwork, $comment, $actor));
|
||||
$notifiedUserIds[] = (int) $parentUser->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
User::query()
|
||||
->whereIn(
|
||||
'id',
|
||||
UserMention::query()
|
||||
->where('comment_id', (int) $comment->id)
|
||||
->pluck('mentioned_user_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->unique()
|
||||
->all()
|
||||
)
|
||||
->get()
|
||||
->each(function (User $mentionedUser) use ($artwork, $comment, $actor): void {
|
||||
if ((int) $mentionedUser->id === (int) $actor->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mentionedUser->notify(new ArtworkMentionedNotification($artwork, $comment, $actor));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Events\Achievements\AchievementCheckRequested;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Notifications\ArtworkLikedNotification;
|
||||
use App\Services\FollowService;
|
||||
use App\Services\UserStatsService;
|
||||
use App\Services\XPService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -14,11 +18,25 @@ use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class ArtworkInteractionController extends Controller
|
||||
{
|
||||
public function bookmark(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$this->toggleSimple(
|
||||
request: $request,
|
||||
table: 'artwork_bookmarks',
|
||||
keyColumns: ['user_id', 'artwork_id'],
|
||||
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
|
||||
insertPayload: ['created_at' => now(), 'updated_at' => now()],
|
||||
requiredTable: 'artwork_bookmarks'
|
||||
);
|
||||
|
||||
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
|
||||
}
|
||||
|
||||
public function favorite(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$state = $request->boolean('state', true);
|
||||
|
||||
$this->toggleSimple(
|
||||
$changed = $this->toggleSimple(
|
||||
request: $request,
|
||||
table: 'artwork_favourites',
|
||||
keyColumns: ['user_id', 'artwork_id'],
|
||||
@@ -33,7 +51,7 @@ final class ArtworkInteractionController extends Controller
|
||||
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
|
||||
if ($creatorId) {
|
||||
$svc = app(UserStatsService::class);
|
||||
if ($state) {
|
||||
if ($state && $changed) {
|
||||
$svc->incrementFavoritesReceived($creatorId);
|
||||
$svc->setLastActiveAt((int) $request->user()->id);
|
||||
|
||||
@@ -46,7 +64,7 @@ final class ArtworkInteractionController extends Controller
|
||||
targetId: $artworkId,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
} else {
|
||||
} elseif (! $state && $changed) {
|
||||
$svc->decrementFavoritesReceived($creatorId);
|
||||
}
|
||||
}
|
||||
@@ -56,7 +74,7 @@ final class ArtworkInteractionController extends Controller
|
||||
|
||||
public function like(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$this->toggleSimple(
|
||||
$changed = $this->toggleSimple(
|
||||
request: $request,
|
||||
table: 'artwork_likes',
|
||||
keyColumns: ['user_id', 'artwork_id'],
|
||||
@@ -67,6 +85,20 @@ final class ArtworkInteractionController extends Controller
|
||||
|
||||
$this->syncArtworkStats($artworkId);
|
||||
|
||||
if ($request->boolean('state', true) && $changed) {
|
||||
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
|
||||
$actorId = (int) $request->user()->id;
|
||||
if ($creatorId > 0 && $creatorId !== $actorId) {
|
||||
app(XPService::class)->awardArtworkLikeReceived($creatorId, $artworkId, $actorId);
|
||||
$creator = \App\Models\User::query()->find($creatorId);
|
||||
$artwork = Artwork::query()->find($artworkId);
|
||||
if ($creator && $artwork) {
|
||||
$creator->notify(new ArtworkLikedNotification($artwork, $request->user()));
|
||||
}
|
||||
event(new AchievementCheckRequested($creatorId));
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
|
||||
}
|
||||
|
||||
@@ -104,8 +136,10 @@ final class ArtworkInteractionController extends Controller
|
||||
return response()->json(['message' => 'Cannot follow yourself'], 422);
|
||||
}
|
||||
|
||||
$svc = app(FollowService::class);
|
||||
$state = $request->boolean('state', true);
|
||||
$svc = app(FollowService::class);
|
||||
$state = $request->has('state')
|
||||
? $request->boolean('state')
|
||||
: ! $request->isMethod('delete');
|
||||
|
||||
if ($state) {
|
||||
$svc->follow($actorId, $userId);
|
||||
@@ -148,7 +182,7 @@ final class ArtworkInteractionController extends Controller
|
||||
array $keyValues,
|
||||
array $insertPayload,
|
||||
string $requiredTable
|
||||
): void {
|
||||
): bool {
|
||||
if (! Schema::hasTable($requiredTable)) {
|
||||
abort(422, 'Interaction unavailable');
|
||||
}
|
||||
@@ -163,10 +197,13 @@ final class ArtworkInteractionController extends Controller
|
||||
if ($state) {
|
||||
if (! $query->exists()) {
|
||||
DB::table($table)->insert(array_merge($keyValues, $insertPayload));
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
$query->delete();
|
||||
return $query->delete() > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function syncArtworkStats(int $artworkId): void
|
||||
@@ -194,6 +231,10 @@ final class ArtworkInteractionController extends Controller
|
||||
|
||||
private function statusPayload(int $viewerId, int $artworkId): array
|
||||
{
|
||||
$isBookmarked = Schema::hasTable('artwork_bookmarks')
|
||||
? DB::table('artwork_bookmarks')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
|
||||
: false;
|
||||
|
||||
$isFavorited = Schema::hasTable('artwork_favourites')
|
||||
? DB::table('artwork_favourites')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
|
||||
: false;
|
||||
@@ -206,15 +247,21 @@ final class ArtworkInteractionController extends Controller
|
||||
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
|
||||
: 0;
|
||||
|
||||
$bookmarks = Schema::hasTable('artwork_bookmarks')
|
||||
? (int) DB::table('artwork_bookmarks')->where('artwork_id', $artworkId)->count()
|
||||
: 0;
|
||||
|
||||
$likes = Schema::hasTable('artwork_likes')
|
||||
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
|
||||
: 0;
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'is_bookmarked' => $isBookmarked,
|
||||
'is_favorited' => $isFavorited,
|
||||
'is_liked' => $isLiked,
|
||||
'stats' => [
|
||||
'bookmarks' => $bookmarks,
|
||||
'favorites' => $favorites,
|
||||
'likes' => $likes,
|
||||
],
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkStatsService;
|
||||
use App\Services\XPService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -26,7 +27,10 @@ use Illuminate\Http\Request;
|
||||
*/
|
||||
final class ArtworkViewController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ArtworkStatsService $stats) {}
|
||||
public function __construct(
|
||||
private readonly ArtworkStatsService $stats,
|
||||
private readonly XPService $xp,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, int $id): JsonResponse
|
||||
{
|
||||
@@ -52,6 +56,16 @@ final class ArtworkViewController extends Controller
|
||||
// Defer to Redis when available, fall back to direct DB increment.
|
||||
$this->stats->incrementViews((int) $artwork->id, 1, defer: true);
|
||||
|
||||
$viewerId = $request->user()?->id;
|
||||
if ($artwork->user_id !== null && (int) $artwork->user_id !== (int) ($viewerId ?? 0)) {
|
||||
$this->xp->awardArtworkViewReceived(
|
||||
(int) $artwork->user_id,
|
||||
(int) $artwork->id,
|
||||
$viewerId,
|
||||
(string) $request->ip(),
|
||||
);
|
||||
}
|
||||
|
||||
// Mark this session so the artwork is not counted again.
|
||||
if ($request->hasSession()) {
|
||||
$request->session()->put($sessionKey, true);
|
||||
|
||||
@@ -36,6 +36,10 @@ final class CommunityActivityController extends Controller
|
||||
|
||||
private function resolveFilter(Request $request): string
|
||||
{
|
||||
if ($request->filled('type') && ! $request->filled('filter')) {
|
||||
return (string) $request->query('type', 'all');
|
||||
}
|
||||
|
||||
if ($request->boolean('following') && ! $request->filled('filter')) {
|
||||
return 'following';
|
||||
}
|
||||
|
||||
35
app/Http/Controllers/Api/LeaderboardController.php
Normal file
35
app/Http/Controllers/Api/LeaderboardController.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Leaderboard;
|
||||
use App\Services\LeaderboardService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class LeaderboardController extends Controller
|
||||
{
|
||||
public function creators(Request $request, LeaderboardService $leaderboards): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$leaderboards->getLeaderboard(Leaderboard::TYPE_CREATOR, (string) $request->query('period', 'weekly'))
|
||||
);
|
||||
}
|
||||
|
||||
public function artworks(Request $request, LeaderboardService $leaderboards): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$leaderboards->getLeaderboard(Leaderboard::TYPE_ARTWORK, (string) $request->query('period', 'weekly'))
|
||||
);
|
||||
}
|
||||
|
||||
public function stories(Request $request, LeaderboardService $leaderboards): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$leaderboards->getLeaderboard(Leaderboard::TYPE_STORY, (string) $request->query('period', 'weekly'))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Services\Posts\NotificationDigestService;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Controller;
|
||||
@@ -14,48 +14,24 @@ use App\Http\Controllers\Controller;
|
||||
*/
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function __construct(private NotificationDigestService $digest) {}
|
||||
public function __construct(private NotificationService $notifications) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$notifications = $user->notifications()
|
||||
->latest()
|
||||
->limit(200) // aggregate from last 200 raw notifs
|
||||
->get();
|
||||
|
||||
$digested = $this->digest->aggregate($notifications);
|
||||
|
||||
// Simple manual pagination on the digested array
|
||||
$perPage = 20;
|
||||
$total = count($digested);
|
||||
$sliced = array_slice($digested, ($page - 1) * $perPage, $perPage);
|
||||
$unread = $user->unreadNotifications()->count();
|
||||
|
||||
return response()->json([
|
||||
'data' => array_values($sliced),
|
||||
'unread_count' => $unread,
|
||||
'meta' => [
|
||||
'total' => $total,
|
||||
'current_page' => $page,
|
||||
'last_page' => (int) ceil($total / $perPage) ?: 1,
|
||||
'per_page' => $perPage,
|
||||
],
|
||||
]);
|
||||
return response()->json(
|
||||
$this->notifications->listForUser($request->user(), (int) $request->query('page', 1), 20)
|
||||
);
|
||||
}
|
||||
|
||||
public function readAll(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->unreadNotifications()->update(['read_at' => now()]);
|
||||
$this->notifications->markAllRead($request->user());
|
||||
return response()->json(['message' => 'All notifications marked as read.']);
|
||||
}
|
||||
|
||||
public function markRead(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$notif = $request->user()->notifications()->findOrFail($id);
|
||||
$notif->markAsRead();
|
||||
$this->notifications->markRead($request->user(), $id);
|
||||
return response()->json(['message' => 'Notification marked as read.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ class PostCommentController extends Controller
|
||||
'username' => $comment->user->username,
|
||||
'name' => $comment->user->name,
|
||||
'avatar' => $comment->user->profile?->avatar_url ?? null,
|
||||
'level' => (int) ($comment->user->level ?? 1),
|
||||
'rank' => (string) ($comment->user->rank ?? 'Newbie'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -7,9 +7,8 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonInterface;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\ThumbnailService;
|
||||
use App\Support\AvatarUrl;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -57,20 +56,9 @@ final class ProfileApiController extends Controller
|
||||
$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();
|
||||
$data = collect($paginator->items())
|
||||
->map(fn (Artwork $art) => $this->mapArtworkCardPayload($art))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
@@ -85,7 +73,8 @@ final class ProfileApiController extends Controller
|
||||
*/
|
||||
public function favourites(Request $request, string $username): JsonResponse
|
||||
{
|
||||
if (! Schema::hasTable('user_favorites')) {
|
||||
$favouriteTable = $this->resolveFavouriteTable();
|
||||
if ($favouriteTable === null) {
|
||||
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
||||
}
|
||||
|
||||
@@ -95,16 +84,18 @@ final class ProfileApiController extends Controller
|
||||
}
|
||||
|
||||
$perPage = 24;
|
||||
$cursor = $request->input('cursor');
|
||||
$offset = max(0, (int) base64_decode((string) $request->input('cursor', ''), true));
|
||||
|
||||
$favIds = DB::table('user_favorites as uf')
|
||||
->join('artworks as a', 'a.id', '=', 'uf.artwork_id')
|
||||
->where('uf.user_id', $user->id)
|
||||
$favIds = DB::table($favouriteTable . ' as af')
|
||||
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
|
||||
->where('af.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)
|
||||
->whereNotNull('a.published_at')
|
||||
->orderByDesc('af.created_at')
|
||||
->orderByDesc('af.artwork_id')
|
||||
->offset($offset)
|
||||
->limit($perPage + 1)
|
||||
->pluck('a.id');
|
||||
|
||||
@@ -120,24 +111,14 @@ final class ProfileApiController extends Controller
|
||||
->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();
|
||||
$data = $favIds
|
||||
->filter(fn ($id) => $indexed->has($id))
|
||||
->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id]))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'next_cursor' => null, // Simple offset pagination for now
|
||||
'next_cursor' => $hasMore ? base64_encode((string) ($offset + $perPage)) : null,
|
||||
'has_more' => $hasMore,
|
||||
]);
|
||||
}
|
||||
@@ -174,4 +155,48 @@ final class ProfileApiController extends Controller
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||
}
|
||||
|
||||
private function resolveFavouriteTable(): ?string
|
||||
{
|
||||
foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) {
|
||||
if (Schema::hasTable($table)) {
|
||||
return $table;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapArtworkCardPayload(Artwork $art): array
|
||||
{
|
||||
$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' => $this->formatIsoDate($art->published_at),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatIsoDate(mixed $value): ?string
|
||||
{
|
||||
if ($value instanceof CarbonInterface) {
|
||||
return $value->toISOString();
|
||||
}
|
||||
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format(DATE_ATOM);
|
||||
}
|
||||
|
||||
return is_string($value) ? $value : null;
|
||||
}
|
||||
}
|
||||
|
||||
34
app/Http/Controllers/Api/SocialActivityController.php
Normal file
34
app/Http/Controllers/Api/SocialActivityController.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ActivityService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class SocialActivityController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ActivityService $activity) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$filter = (string) $request->query('filter', 'all');
|
||||
|
||||
if ($this->activity->requiresAuthentication($filter) && ! $request->user()) {
|
||||
return response()->json(['error' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
return response()->json(
|
||||
$this->activity->communityFeed(
|
||||
viewer: $request->user(),
|
||||
filter: $filter,
|
||||
page: (int) $request->query('page', 1),
|
||||
perPage: (int) $request->query('per_page', 20),
|
||||
actorUserId: $request->filled('user_id') ? (int) $request->query('user_id') : null,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
212
app/Http/Controllers/Api/SocialCompatibilityController.php
Normal file
212
app/Http/Controllers/Api/SocialCompatibilityController.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryBookmark;
|
||||
use App\Services\SocialService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class SocialCompatibilityController extends Controller
|
||||
{
|
||||
public function __construct(private readonly SocialService $social) {}
|
||||
|
||||
public function like(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'entity_type' => ['required', 'string', 'in:artwork,story'],
|
||||
'entity_id' => ['required', 'integer'],
|
||||
'state' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$state = array_key_exists('state', $payload)
|
||||
? (bool) $payload['state']
|
||||
: ! $request->isMethod('delete');
|
||||
|
||||
if ($payload['entity_type'] === 'story') {
|
||||
$story = Story::published()->findOrFail((int) $payload['entity_id']);
|
||||
|
||||
$result = $this->social->toggleStoryLike($request->user(), $story, $state);
|
||||
|
||||
return response()->json([
|
||||
'ok' => (bool) ($result['ok'] ?? true),
|
||||
'liked' => (bool) ($result['liked'] ?? false),
|
||||
'likes_count' => (int) ($result['likes_count'] ?? 0),
|
||||
'is_liked' => (bool) ($result['liked'] ?? false),
|
||||
'stats' => [
|
||||
'likes' => (int) ($result['likes_count'] ?? 0),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$artworkId = (int) $payload['entity_id'];
|
||||
abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404);
|
||||
|
||||
return app(ArtworkInteractionController::class)->like(
|
||||
$request->merge(['state' => $state]),
|
||||
$artworkId,
|
||||
);
|
||||
}
|
||||
|
||||
public function comments(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'entity_type' => ['required', 'string', 'in:artwork,story'],
|
||||
'entity_id' => ['required', 'integer'],
|
||||
'content' => [$request->isMethod('get') ? 'nullable' : 'required', 'string', 'min:1', 'max:10000'],
|
||||
'parent_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
if ($payload['entity_type'] === 'story') {
|
||||
if ($request->isMethod('get')) {
|
||||
$story = Story::published()->findOrFail((int) $payload['entity_id']);
|
||||
|
||||
return response()->json(
|
||||
$this->social->listStoryComments($story, $request->user()?->id, (int) $request->query('page', 1), 20)
|
||||
);
|
||||
}
|
||||
|
||||
$story = Story::published()->findOrFail((int) $payload['entity_id']);
|
||||
$comment = $this->social->addStoryComment(
|
||||
$request->user(),
|
||||
$story,
|
||||
(string) $payload['content'],
|
||||
isset($payload['parent_id']) ? (int) $payload['parent_id'] : null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->social->formatComment($comment, (int) $request->user()->id, true),
|
||||
], 201);
|
||||
}
|
||||
|
||||
$artworkId = (int) $payload['entity_id'];
|
||||
abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404);
|
||||
|
||||
if ($request->isMethod('get')) {
|
||||
return app(ArtworkCommentController::class)->index($request, $artworkId);
|
||||
}
|
||||
|
||||
return app(ArtworkCommentController::class)->store(
|
||||
$request->merge([
|
||||
'content' => $payload['content'],
|
||||
'parent_id' => $payload['parent_id'] ?? null,
|
||||
]),
|
||||
$artworkId,
|
||||
);
|
||||
}
|
||||
|
||||
public function bookmark(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'entity_type' => ['required', 'string', 'in:artwork,story'],
|
||||
'entity_id' => ['required', 'integer'],
|
||||
'state' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$state = array_key_exists('state', $payload)
|
||||
? (bool) $payload['state']
|
||||
: ! $request->isMethod('delete');
|
||||
|
||||
if ($payload['entity_type'] === 'story') {
|
||||
$story = Story::published()->findOrFail((int) $payload['entity_id']);
|
||||
|
||||
$result = $this->social->toggleStoryBookmark($request->user(), $story, $state);
|
||||
|
||||
return response()->json([
|
||||
'ok' => (bool) ($result['ok'] ?? true),
|
||||
'bookmarked' => (bool) ($result['bookmarked'] ?? false),
|
||||
'bookmarks_count' => (int) ($result['bookmarks_count'] ?? 0),
|
||||
'is_bookmarked' => (bool) ($result['bookmarked'] ?? false),
|
||||
'stats' => [
|
||||
'bookmarks' => (int) ($result['bookmarks_count'] ?? 0),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$artworkId = (int) $payload['entity_id'];
|
||||
abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404);
|
||||
|
||||
return app(ArtworkInteractionController::class)->bookmark(
|
||||
$request->merge(['state' => $state]),
|
||||
$artworkId,
|
||||
);
|
||||
}
|
||||
|
||||
public function bookmarks(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'entity_type' => ['nullable', 'string', 'in:artwork,story'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||
]);
|
||||
|
||||
$perPage = (int) ($payload['per_page'] ?? 20);
|
||||
$userId = (int) $request->user()->id;
|
||||
$type = $payload['entity_type'] ?? null;
|
||||
|
||||
$items = collect();
|
||||
|
||||
if ($type === null || $type === 'artwork') {
|
||||
$items = $items->concat(
|
||||
Schema::hasTable('artwork_bookmarks')
|
||||
? DB::table('artwork_bookmarks')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_bookmarks.artwork_id')
|
||||
->where('artwork_bookmarks.user_id', $userId)
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->select([
|
||||
'artwork_bookmarks.created_at as saved_at',
|
||||
'artworks.id',
|
||||
'artworks.title',
|
||||
'artworks.slug',
|
||||
])
|
||||
->latest('artwork_bookmarks.created_at')
|
||||
->limit($perPage)
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'type' => 'artwork',
|
||||
'id' => (int) $row->id,
|
||||
'title' => (string) $row->title,
|
||||
'url' => route('art.show', ['id' => (int) $row->id, 'slug' => Str::slug((string) ($row->slug ?: $row->title)) ?: (string) $row->id]),
|
||||
'saved_at' => Carbon::parse($row->saved_at)->toIso8601String(),
|
||||
])
|
||||
: collect()
|
||||
);
|
||||
}
|
||||
|
||||
if ($type === null || $type === 'story') {
|
||||
$items = $items->concat(
|
||||
StoryBookmark::query()
|
||||
->with('story:id,slug,title')
|
||||
->where('user_id', $userId)
|
||||
->latest('created_at')
|
||||
->limit($perPage)
|
||||
->get()
|
||||
->filter(fn (StoryBookmark $bookmark) => $bookmark->story !== null)
|
||||
->map(fn (StoryBookmark $bookmark) => [
|
||||
'type' => 'story',
|
||||
'id' => (int) $bookmark->story->id,
|
||||
'title' => (string) $bookmark->story->title,
|
||||
'url' => route('stories.show', ['slug' => $bookmark->story->slug]),
|
||||
'saved_at' => $bookmark->created_at?->toIso8601String(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $items
|
||||
->sortByDesc('saved_at')
|
||||
->take($perPage)
|
||||
->values()
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Api/StoryCommentController.php
Normal file
58
app/Http/Controllers/Api/StoryCommentController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryComment;
|
||||
use App\Services\SocialService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class StoryCommentController extends Controller
|
||||
{
|
||||
public function __construct(private readonly SocialService $social) {}
|
||||
|
||||
public function index(Request $request, int $storyId): JsonResponse
|
||||
{
|
||||
$story = Story::published()->findOrFail($storyId);
|
||||
|
||||
return response()->json(
|
||||
$this->social->listStoryComments($story, $request->user()?->id, (int) $request->query('page', 1), 20)
|
||||
);
|
||||
}
|
||||
|
||||
public function store(Request $request, int $storyId): JsonResponse
|
||||
{
|
||||
$story = Story::published()->findOrFail($storyId);
|
||||
|
||||
$payload = $request->validate([
|
||||
'content' => ['required', 'string', 'min:1', 'max:10000'],
|
||||
'parent_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$comment = $this->social->addStoryComment(
|
||||
$request->user(),
|
||||
$story,
|
||||
(string) $payload['content'],
|
||||
isset($payload['parent_id']) ? (int) $payload['parent_id'] : null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->social->formatComment($comment, $request->user()->id, true),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $storyId, int $commentId): JsonResponse
|
||||
{
|
||||
$comment = StoryComment::query()
|
||||
->where('story_id', $storyId)
|
||||
->findOrFail($commentId);
|
||||
|
||||
$this->social->deleteStoryComment($request->user(), $comment);
|
||||
|
||||
return response()->json(['message' => 'Comment deleted.']);
|
||||
}
|
||||
}
|
||||
34
app/Http/Controllers/Api/StoryInteractionController.php
Normal file
34
app/Http/Controllers/Api/StoryInteractionController.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Story;
|
||||
use App\Services\SocialService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class StoryInteractionController extends Controller
|
||||
{
|
||||
public function __construct(private readonly SocialService $social) {}
|
||||
|
||||
public function like(Request $request, int $storyId): JsonResponse
|
||||
{
|
||||
$story = Story::published()->findOrFail($storyId);
|
||||
|
||||
return response()->json(
|
||||
$this->social->toggleStoryLike($request->user(), $story, $request->boolean('state', true))
|
||||
);
|
||||
}
|
||||
|
||||
public function bookmark(Request $request, int $storyId): JsonResponse
|
||||
{
|
||||
$story = Story::published()->findOrFail($storyId);
|
||||
|
||||
return response()->json(
|
||||
$this->social->toggleStoryBookmark($request->user(), $story, $request->boolean('state', true))
|
||||
);
|
||||
}
|
||||
}
|
||||
18
app/Http/Controllers/Api/UserAchievementsController.php
Normal file
18
app/Http/Controllers/Api/UserAchievementsController.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AchievementService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class UserAchievementsController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, AchievementService $achievements): JsonResponse
|
||||
{
|
||||
return response()->json($achievements->summary($request->user()->id));
|
||||
}
|
||||
}
|
||||
18
app/Http/Controllers/Api/UserXpController.php
Normal file
18
app/Http/Controllers/Api/UserXpController.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\XPService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class UserXpController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, XPService $xp): JsonResponse
|
||||
{
|
||||
return response()->json($xp->summary($request->user()->id));
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,10 @@ final class ArtworkDownloadController extends Controller
|
||||
}
|
||||
|
||||
$filePath = $this->resolveOriginalPath($hash, $ext);
|
||||
|
||||
$this->recordDownload($request, $artwork->id);
|
||||
$this->incrementDownloadCountIfAvailable($artwork->id);
|
||||
|
||||
if (! File::isFile($filePath)) {
|
||||
Log::warning('Artwork original file missing for download.', [
|
||||
'artwork_id' => $artwork->id,
|
||||
@@ -55,8 +59,6 @@ final class ArtworkDownloadController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->recordDownload($request, $artwork->id);
|
||||
$this->incrementDownloadCountIfAvailable($artwork->id);
|
||||
|
||||
$downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext);
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ class LatestController extends Controller
|
||||
$perPage = 21;
|
||||
|
||||
$artworks = $this->artworks->browsePublicArtworks($perPage);
|
||||
$artworks->getCollection()->load([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
]);
|
||||
|
||||
$artworks->getCollection()->transform(function (Artwork $artwork) {
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
@@ -34,10 +38,18 @@ class LatestController extends Controller
|
||||
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
||||
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
|
||||
'category_name' => $categoryName,
|
||||
'category_slug' => $primaryCategory?->slug ?? '',
|
||||
'gid_num' => $gid,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
'username' => $artwork->user->username ?? '',
|
||||
'user_id' => $artwork->user->id,
|
||||
'avatar_hash' => $artwork->user->profile->avatar_hash ?? null,
|
||||
'avatar_url' => \App\Support\AvatarUrl::forUser((int) $artwork->user->id, $artwork->user->profile->avatar_hash ?? null, 64),
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'published_at' => $artwork->published_at, // required by CursorPaginator
|
||||
];
|
||||
});
|
||||
|
||||
@@ -3,16 +3,67 @@
|
||||
namespace App\Http\Controllers\Dashboard;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ReceivedCommentsInboxService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
public function __construct(private readonly ReceivedCommentsInboxService $inbox) {}
|
||||
|
||||
public function received(Request $request): View
|
||||
{
|
||||
$user = $request->user();
|
||||
// Minimal placeholder: real implementation should query comments received or made
|
||||
$comments = [];
|
||||
$search = trim((string) $request->query('q', ''));
|
||||
$sort = strtolower((string) $request->query('sort', 'newest'));
|
||||
|
||||
return view('dashboard.comments', ['comments' => $comments]);
|
||||
if (! in_array($sort, ['newest', 'oldest'], true)) {
|
||||
$sort = 'newest';
|
||||
}
|
||||
|
||||
$baseQuery = $this->inbox->queryForUser($user)
|
||||
->with(['user.profile', 'artwork']);
|
||||
|
||||
if ($search !== '') {
|
||||
$baseQuery->where(function ($query) use ($search): void {
|
||||
$query->where('content', 'like', '%' . $search . '%')
|
||||
->orWhere('raw_content', 'like', '%' . $search . '%')
|
||||
->orWhereHas('artwork', function ($artworkQuery) use ($search): void {
|
||||
$artworkQuery->where('title', 'like', '%' . $search . '%')
|
||||
->orWhere('slug', 'like', '%' . $search . '%');
|
||||
})
|
||||
->orWhereHas('user', function ($userQuery) use ($search): void {
|
||||
$userQuery->where('username', 'like', '%' . $search . '%')
|
||||
->orWhere('name', 'like', '%' . $search . '%');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$orderedQuery = (clone $baseQuery)
|
||||
->orderBy('created_at', $sort === 'oldest' ? 'asc' : 'desc');
|
||||
|
||||
$comments = $orderedQuery->paginate(12)->withQueryString();
|
||||
|
||||
$statsBaseQuery = clone $baseQuery;
|
||||
$freshlyClearedCount = $this->inbox->unreadCountForUser($user);
|
||||
$totalComments = (clone $statsBaseQuery)->count();
|
||||
$recentComments = (clone $statsBaseQuery)->where('created_at', '>=', now()->subDays(7))->count();
|
||||
$uniqueCommenters = (clone $statsBaseQuery)->distinct('user_id')->count('user_id');
|
||||
$activeArtworks = (clone $statsBaseQuery)->distinct('artwork_id')->count('artwork_id');
|
||||
|
||||
$this->inbox->markInboxRead($user);
|
||||
|
||||
return view('dashboard.comments', [
|
||||
'comments' => $comments,
|
||||
'search' => $search,
|
||||
'sort' => $sort,
|
||||
'freshlyClearedCount' => $freshlyClearedCount,
|
||||
'stats' => [
|
||||
'total' => $totalComments,
|
||||
'recent' => $recentComments,
|
||||
'commenters' => $uniqueCommenters,
|
||||
'artworks' => $activeArtworks,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Dashboard;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DashboardPreference;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardPreferenceController extends Controller
|
||||
{
|
||||
public function updateShortcuts(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'pinned_spaces' => ['present', 'array', 'max:' . DashboardPreference::MAX_PINNED_SPACES],
|
||||
'pinned_spaces.*' => ['string'],
|
||||
]);
|
||||
|
||||
$pinnedSpaces = DashboardPreference::sanitizePinnedSpaces($validated['pinned_spaces'] ?? []);
|
||||
|
||||
DashboardPreference::query()->updateOrCreate(
|
||||
['user_id' => $request->user()->id],
|
||||
['pinned_spaces' => $pinnedSpaces]
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'pinned_spaces' => $pinnedSpaces,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
25
app/Http/Controllers/Dashboard/NotificationController.php
Normal file
25
app/Http/Controllers/Dashboard/NotificationController.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Dashboard;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function __construct(private readonly NotificationService $notifications) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$payload = $this->notifications->listForUser($request->user(), $page, 15);
|
||||
|
||||
return view('dashboard.notifications', [
|
||||
'notifications' => collect($payload['data'] ?? []),
|
||||
'notificationsMeta' => $payload['meta'] ?? [],
|
||||
'unreadCount' => (int) ($payload['unread_count'] ?? 0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,25 +5,60 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\DashboardPreference;
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
use App\Services\ReceivedCommentsInboxService;
|
||||
use App\Services\XPService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class DashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly XPService $xp,
|
||||
private readonly ReceivedCommentsInboxService $receivedCommentsInbox,
|
||||
) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$xpSummary = $this->xp->summary((int) $user->id);
|
||||
$artworksCount = Artwork::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
$storiesCount = Story::query()->where('creator_id', $user->id)->count();
|
||||
$followersCount = (int) DB::table('user_followers')->where('user_id', $user->id)->count();
|
||||
$followingCount = (int) DB::table('user_followers')->where('follower_id', $user->id)->count();
|
||||
$favoritesCount = (int) DB::table('artwork_favourites')->where('user_id', $user->id)->count();
|
||||
$unreadNotificationsCount = $user->unreadNotifications()->count();
|
||||
$receivedCommentsCount = $this->receivedCommentsInbox->unreadCountForUser($user);
|
||||
$isCreator = $artworksCount > 0;
|
||||
$pinnedSpaces = DashboardPreference::pinnedSpacesForUser($user);
|
||||
|
||||
return view('dashboard', [
|
||||
'page_title' => 'Dashboard',
|
||||
'dashboard_user_name' => $user?->username ?: $user?->name ?: 'Creator',
|
||||
'dashboard_is_creator' => Artwork::query()->where('user_id', $user->id)->exists(),
|
||||
'dashboard_is_creator' => $isCreator,
|
||||
'dashboard_level' => $xpSummary['level'],
|
||||
'dashboard_rank' => $xpSummary['rank'],
|
||||
'dashboard_received_comments_count' => $receivedCommentsCount,
|
||||
'dashboard_overview' => [
|
||||
'artworks' => $artworksCount,
|
||||
'stories' => $storiesCount,
|
||||
'followers' => $followersCount,
|
||||
'following' => $followingCount,
|
||||
'favorites' => $favoritesCount,
|
||||
'notifications' => $unreadNotificationsCount,
|
||||
'received_comments' => $receivedCommentsCount,
|
||||
],
|
||||
'dashboard_preferences' => [
|
||||
'pinned_spaces' => $pinnedSpaces,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -195,6 +230,8 @@ final class DashboardController extends Controller
|
||||
'username' => $artwork->user?->username,
|
||||
'name' => $artwork->user?->name,
|
||||
'url' => $artwork->user?->username ? '/@' . $artwork->user->username : null,
|
||||
'level' => (int) ($artwork->user?->level ?? 1),
|
||||
'rank' => (string) ($artwork->user?->rank ?? 'Newbie'),
|
||||
],
|
||||
];
|
||||
})
|
||||
@@ -238,6 +275,8 @@ final class DashboardController extends Controller
|
||||
'users.id',
|
||||
'users.username',
|
||||
'users.name',
|
||||
'users.level',
|
||||
'users.rank',
|
||||
'up.avatar_hash',
|
||||
DB::raw('COALESCE(us.followers_count, 0) as followers_count'),
|
||||
DB::raw('COALESCE(us.uploads_count, 0) as uploads_count'),
|
||||
@@ -255,6 +294,8 @@ final class DashboardController extends Controller
|
||||
'name' => $row->name,
|
||||
'url' => $username !== '' ? '/@' . $username : null,
|
||||
'avatar' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
|
||||
'level' => (int) ($row->level ?? 1),
|
||||
'rank' => (string) ($row->rank ?? 'Newbie'),
|
||||
'followers_count' => (int) $row->followers_count,
|
||||
'uploads_count' => (int) $row->uploads_count,
|
||||
];
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class GalleryController extends Controller
|
||||
{
|
||||
public function show(Request $request, $userId, $username = null)
|
||||
{
|
||||
$user = User::find((int)$userId);
|
||||
if (! $user) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$hits = 20;
|
||||
|
||||
$query = Artwork::where('user_id', $user->id)
|
||||
->approved()
|
||||
->published()
|
||||
->public()
|
||||
->orderByDesc('published_at');
|
||||
|
||||
$total = (int) $query->count();
|
||||
|
||||
$artworks = $query->skip(($page - 1) * $hits)->take($hits)->get();
|
||||
|
||||
return view('legacy::gallery', [
|
||||
'user' => $user,
|
||||
'artworks' => $artworks,
|
||||
'page' => $page,
|
||||
'hits' => $hits,
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BuddiesController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$perPage = 50;
|
||||
|
||||
try {
|
||||
$query = DB::table('friends_list as t1')
|
||||
->leftJoin('users as t2', 't1.user_id', '=', 't2.id')
|
||||
->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id')
|
||||
->where('t1.friend_id', $user->id)
|
||||
->select('t1.id', 't1.user_id', 't1.friend_id', 't2.name as uname', 'p.avatar_hash as icon', 't1.date_added')
|
||||
->orderByDesc('t1.date_added');
|
||||
|
||||
$followers = $query->paginate($perPage)->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$followers = collect();
|
||||
}
|
||||
|
||||
$page_title = ($user->name ?? $user->username ?? 'User') . ': Followers';
|
||||
|
||||
return view('legacy::buddies', compact('followers', 'page_title'));
|
||||
}
|
||||
}
|
||||
62
app/Http/Controllers/Legacy/CategoryRedirectController.php
Normal file
62
app/Http/Controllers/Legacy/CategoryRedirectController.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CategoryRedirectController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, string $group, ?string $slug = null, ?string $id = null): RedirectResponse
|
||||
{
|
||||
$groupSlug = strtolower(trim($group, '/'));
|
||||
$slugPart = strtolower(trim((string) $slug, '/'));
|
||||
|
||||
$category = $this->resolveCategory($groupSlug, $slugPart, $id);
|
||||
|
||||
if ($category && $category->contentType) {
|
||||
$target = $category->url;
|
||||
|
||||
if ($request->getQueryString()) {
|
||||
$target .= '?' . $request->getQueryString();
|
||||
}
|
||||
|
||||
return redirect()->to($target, 301);
|
||||
}
|
||||
|
||||
return redirect()->route('categories.index', $request->query(), 301);
|
||||
}
|
||||
|
||||
private function resolveCategory(string $groupSlug, string $slugPart, ?string $id): ?Category
|
||||
{
|
||||
if ($id !== null && ctype_digit((string) $id)) {
|
||||
$category = Category::query()
|
||||
->with('contentType')
|
||||
->find((int) $id);
|
||||
|
||||
if ($category) {
|
||||
return $category;
|
||||
}
|
||||
}
|
||||
|
||||
if ($slugPart !== '') {
|
||||
$category = Category::query()
|
||||
->with('contentType')
|
||||
->where('slug', $slugPart)
|
||||
->whereHas('parent', fn ($query) => $query->where('slug', $groupSlug))
|
||||
->first();
|
||||
|
||||
if ($category) {
|
||||
return $category;
|
||||
}
|
||||
}
|
||||
|
||||
return Category::query()
|
||||
->with('contentType')
|
||||
->where('slug', $groupSlug)
|
||||
->whereNull('parent_id')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -3,48 +3,54 @@
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\LegacyService;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MembersController extends Controller
|
||||
{
|
||||
protected LegacyService $legacy;
|
||||
protected ArtworkService $artworks;
|
||||
|
||||
public function __construct(LegacyService $legacy)
|
||||
public function __construct(ArtworkService $artworks)
|
||||
{
|
||||
$this->legacy = $legacy;
|
||||
$this->artworks = $artworks;
|
||||
}
|
||||
|
||||
public function photos(Request $request, $id = null)
|
||||
{
|
||||
$id = (int) ($id ?: 545);
|
||||
$artworks = $this->artworks->getArtworksByContentType('photography', 40);
|
||||
|
||||
$result = $this->legacy->categoryPage('', null, $id);
|
||||
if (! $result) {
|
||||
return redirect('/');
|
||||
}
|
||||
$artworks->getCollection()->load([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
]);
|
||||
|
||||
// categoryPage returns an array with keys used by legacy.browse
|
||||
$page_title = $result['page_title'] ?? ($result['category']->category_name ?? 'Members Photos');
|
||||
$artworks = $result['artworks'] ?? collect();
|
||||
$artworks->getCollection()->transform(function (Artwork $artwork) {
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
|
||||
|
||||
// Ensure artworks include `slug`, `thumb`, and `thumb_srcset` properties expected by the legacy view
|
||||
if ($artworks && method_exists($artworks, 'getCollection')) {
|
||||
$artworks->getCollection()->transform(function ($row) {
|
||||
$row->slug = $row->slug ?? Str::slug($row->name ?? '');
|
||||
$row->thumb = $row->thumb ?? ($row->thumb_url ?? null);
|
||||
$row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null);
|
||||
return $row;
|
||||
});
|
||||
} elseif (is_iterable($artworks)) {
|
||||
$artworks = collect($artworks)->map(function ($row) {
|
||||
$row->slug = $row->slug ?? Str::slug($row->name ?? '');
|
||||
$row->thumb = $row->thumb ?? ($row->thumb_url ?? null);
|
||||
$row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null);
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
return (object) [
|
||||
'id' => $artwork->id,
|
||||
'name' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
'username' => $artwork->user->username ?? '',
|
||||
'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($artwork->user->id ?? 0), $artwork->user->profile->avatar_hash ?? null, 64),
|
||||
'content_type_name' => $primaryCategory?->contentType?->name ?? 'Photography',
|
||||
'content_type_slug' => $primaryCategory?->contentType?->slug ?? 'photography',
|
||||
'category_name' => $primaryCategory?->name ?? '',
|
||||
'category_slug' => $primaryCategory?->slug ?? '',
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'published_at' => $artwork->published_at,
|
||||
];
|
||||
});
|
||||
|
||||
$page_title = 'Member Photos';
|
||||
|
||||
return view('legacy::browse', compact('page_title', 'artworks'));
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class MyBuddiesController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$perPage = 50;
|
||||
|
||||
try {
|
||||
$query = DB::table('friends_list as t1')
|
||||
->leftJoin('users as t2', 't1.friend_id', '=', 't2.id')
|
||||
->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id')
|
||||
->where('t1.user_id', $user->id)
|
||||
->select('t1.id', 't1.friend_id', 't1.user_id', 't2.name as uname', 'p.avatar_hash as icon', 't1.date_added')
|
||||
->orderByDesc('t1.date_added');
|
||||
|
||||
$buddies = $query->paginate($perPage)->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$buddies = collect();
|
||||
}
|
||||
|
||||
$page_title = ($user->name ?? $user->username ?? 'User') . ': Following List';
|
||||
|
||||
return view('legacy::mybuddies', compact('buddies', 'page_title'));
|
||||
}
|
||||
|
||||
public function destroy(Request $request, $id)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$deleted = DB::table('friends_list')->where('id', $id)->where('user_id', $user->id)->delete();
|
||||
if ($deleted) {
|
||||
$request->session()->flash('status', 'Removed from following list.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$request->session()->flash('error', 'Could not remove buddy.');
|
||||
}
|
||||
|
||||
return redirect()->route('legacy.mybuddies');
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class ReceivedCommentsController extends Controller
|
||||
}
|
||||
|
||||
$hits = 33;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$currentPage = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$base = ArtworkComment::with(['user', 'artwork'])
|
||||
->whereHas('artwork', function ($q) use ($user) {
|
||||
@@ -30,7 +30,7 @@ class ReceivedCommentsController extends Controller
|
||||
|
||||
return view('legacy::received-comments', [
|
||||
'comments' => $comments,
|
||||
'page' => $page,
|
||||
'currentPage' => $currentPage,
|
||||
'hits' => $hits,
|
||||
'total' => $comments->total(),
|
||||
]);
|
||||
|
||||
@@ -8,18 +8,20 @@ use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMNode;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\StoryComment;
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryTag;
|
||||
use App\Models\StoryView;
|
||||
use App\Models\User;
|
||||
use App\Notifications\StoryStatusNotification;
|
||||
use App\Services\SocialService;
|
||||
use App\Services\StoryPublicationService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -88,7 +90,7 @@ class StoryController extends Controller
|
||||
public function show(Request $request, string $slug): View
|
||||
{
|
||||
$story = Story::published()
|
||||
->with(['creator.profile', 'tags'])
|
||||
->with(['creator.profile', 'creator.statistics', 'tags'])
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
|
||||
@@ -127,28 +129,49 @@ class StoryController extends Controller
|
||||
->get(['id', 'title', 'slug']);
|
||||
}
|
||||
|
||||
$discussionComments = collect();
|
||||
if ($story->creator_id !== null && Schema::hasTable('profile_comments')) {
|
||||
$discussionComments = DB::table('profile_comments as pc')
|
||||
->join('users as u', 'u.id', '=', 'pc.author_user_id')
|
||||
->where('pc.profile_user_id', $story->creator_id)
|
||||
->where('pc.is_active', true)
|
||||
->orderByDesc('pc.created_at')
|
||||
->limit(8)
|
||||
->get([
|
||||
'pc.id',
|
||||
'pc.body',
|
||||
'pc.created_at',
|
||||
'u.username as author_username',
|
||||
]);
|
||||
}
|
||||
$social = app(SocialService::class);
|
||||
$initialComments = Schema::hasTable('story_comments')
|
||||
? StoryComment::query()
|
||||
->with(['user.profile', 'approvedReplies'])
|
||||
->where('story_id', $story->id)
|
||||
->where('is_approved', true)
|
||||
->whereNull('parent_id')
|
||||
->whereNull('deleted_at')
|
||||
->latest('created_at')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn (StoryComment $comment) => $social->formatComment($comment, $request->user()?->id, true))
|
||||
->values()
|
||||
->all()
|
||||
: [];
|
||||
|
||||
$storyState = $social->storyStateFor($request->user(), $story);
|
||||
|
||||
$storySocialProps = [
|
||||
'story' => [
|
||||
'id' => (int) $story->id,
|
||||
'slug' => (string) $story->slug,
|
||||
'title' => (string) $story->title,
|
||||
],
|
||||
'creator' => $story->creator ? [
|
||||
'id' => (int) $story->creator->id,
|
||||
'username' => (string) ($story->creator->username ?? ''),
|
||||
'display_name' => (string) ($story->creator->name ?: $story->creator->username ?: 'Creator'),
|
||||
'avatar_url' => AvatarUrl::forUser((int) $story->creator->id, $story->creator->profile?->avatar_hash, 128),
|
||||
'followers_count' => (int) ($story->creator->statistics?->followers_count ?? 0),
|
||||
'profile_url' => $story->creator->username ? '/@' . $story->creator->username : null,
|
||||
] : null,
|
||||
'state' => $storyState,
|
||||
'comments' => $initialComments,
|
||||
'is_authenticated' => $request->user() !== null,
|
||||
];
|
||||
|
||||
return view('web.stories.show', [
|
||||
'story' => $story,
|
||||
'safeContent' => $storyContentHtml,
|
||||
'relatedStories' => $relatedStories,
|
||||
'relatedArtworks' => $relatedArtworks,
|
||||
'comments' => $discussionComments,
|
||||
'storySocialProps' => $storySocialProps,
|
||||
'page_title' => $story->title . ' - Skinbase Stories',
|
||||
'page_meta_description' => $story->excerpt ?: Str::limit(strip_tags((string) $story->content), 160),
|
||||
'page_canonical' => route('stories.show', $story->slug),
|
||||
@@ -212,6 +235,10 @@ class StoryController extends Controller
|
||||
|
||||
$story->tags()->sync($this->resolveTagIds($validated));
|
||||
|
||||
if ($resolved['status'] === 'published') {
|
||||
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
|
||||
}
|
||||
|
||||
if ($resolved['status'] === 'published') {
|
||||
return redirect()->route('stories.show', ['slug' => $story->slug])
|
||||
->with('status', 'Story published.');
|
||||
@@ -275,6 +302,8 @@ class StoryController extends Controller
|
||||
{
|
||||
abort_unless($this->canManageStory($request, $story), 403);
|
||||
|
||||
$wasPublished = $story->published_at !== null || $story->status === 'published';
|
||||
|
||||
$validated = $this->validateStoryPayload($request);
|
||||
$resolved = $this->resolveWorkflowState($request, $validated, false);
|
||||
$serializedContent = $this->normalizeStoryContent($validated['content'] ?? []);
|
||||
@@ -302,6 +331,10 @@ class StoryController extends Controller
|
||||
|
||||
$story->tags()->sync($this->resolveTagIds($validated));
|
||||
|
||||
if (! $wasPublished && $resolved['status'] === 'published') {
|
||||
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
|
||||
}
|
||||
|
||||
return back()->with('status', 'Story updated.');
|
||||
}
|
||||
|
||||
@@ -370,14 +403,10 @@ class StoryController extends Controller
|
||||
{
|
||||
abort_unless($this->canManageStory($request, $story), 403);
|
||||
|
||||
$story->update([
|
||||
'status' => 'published',
|
||||
app(StoryPublicationService::class)->publish($story, 'published', [
|
||||
'published_at' => now(),
|
||||
'scheduled_for' => null,
|
||||
]);
|
||||
|
||||
$story->creator?->notify(new StoryStatusNotification($story, 'published'));
|
||||
|
||||
return redirect()->route('stories.show', ['slug' => $story->slug])->with('status', 'Story published.');
|
||||
}
|
||||
|
||||
@@ -512,11 +541,19 @@ class StoryController extends Controller
|
||||
$story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']]));
|
||||
}
|
||||
|
||||
if ($workflow['status'] === 'published') {
|
||||
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'story_id' => (int) $story->id,
|
||||
'status' => $story->status,
|
||||
'message' => 'Story created.',
|
||||
'edit_url' => route('creator.stories.edit', ['story' => $story->id]),
|
||||
'preview_url' => route('creator.stories.preview', ['story' => $story->id]),
|
||||
'analytics_url' => route('creator.stories.analytics', ['story' => $story->id]),
|
||||
'public_url' => route('stories.show', ['slug' => $story->slug]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -540,6 +577,7 @@ class StoryController extends Controller
|
||||
|
||||
$story = Story::query()->findOrFail((int) $validated['story_id']);
|
||||
abort_unless($this->canManageStory($request, $story), 403);
|
||||
$wasPublished = $story->published_at !== null || $story->status === 'published';
|
||||
|
||||
$workflow = $this->resolveWorkflowState($request, array_merge([
|
||||
'status' => $story->status,
|
||||
@@ -576,11 +614,19 @@ class StoryController extends Controller
|
||||
$story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']]));
|
||||
}
|
||||
|
||||
if (! $wasPublished && $workflow['status'] === 'published') {
|
||||
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'story_id' => (int) $story->id,
|
||||
'status' => $story->status,
|
||||
'message' => 'Story updated.',
|
||||
'edit_url' => route('creator.stories.edit', ['story' => $story->id]),
|
||||
'preview_url' => route('creator.stories.preview', ['story' => $story->id]),
|
||||
'analytics_url' => route('creator.stories.analytics', ['story' => $story->id]),
|
||||
'public_url' => route('stories.show', ['slug' => $story->slug]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -631,6 +677,7 @@ class StoryController extends Controller
|
||||
'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null),
|
||||
]);
|
||||
} else {
|
||||
$wasPublished = $story->published_at !== null || $story->status === 'published';
|
||||
$nextContent = array_key_exists('content', $validated)
|
||||
? $this->normalizeStoryContent($validated['content'])
|
||||
: (string) $story->content;
|
||||
@@ -655,6 +702,14 @@ class StoryController extends Controller
|
||||
'scheduled_for' => ! empty($validated['scheduled_for']) ? now()->parse((string) $validated['scheduled_for']) : $story->scheduled_for,
|
||||
]);
|
||||
$story->save();
|
||||
|
||||
if (! $wasPublished && $story->status === 'published') {
|
||||
if ($story->published_at === null) {
|
||||
$story->forceFill(['published_at' => now()])->save();
|
||||
}
|
||||
|
||||
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($validated['tags_csv'])) {
|
||||
@@ -666,6 +721,10 @@ class StoryController extends Controller
|
||||
'story_id' => (int) $story->id,
|
||||
'saved_at' => now()->toIso8601String(),
|
||||
'message' => 'Saved just now',
|
||||
'edit_url' => route('creator.stories.edit', ['story' => $story->id]),
|
||||
'preview_url' => route('creator.stories.preview', ['story' => $story->id]),
|
||||
'analytics_url' => route('creator.stories.analytics', ['story' => $story->id]),
|
||||
'public_url' => route('stories.show', ['slug' => $story->slug]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1047,7 +1106,7 @@ class StoryController extends Controller
|
||||
'orderedList' => '<ol>' . $inner . '</ol>',
|
||||
'listItem' => '<li>' . $inner . '</li>',
|
||||
'horizontalRule' => '<hr>',
|
||||
'codeBlock' => '<pre><code>' . e($this->extractTipTapText($node)) . '</code></pre>',
|
||||
'codeBlock' => $this->renderCodeBlockNode($attrs, $node),
|
||||
'image' => $this->renderImageNode($attrs),
|
||||
'artworkEmbed' => $this->renderArtworkEmbedNode($attrs),
|
||||
'galleryBlock' => $this->renderGalleryBlockNode($attrs),
|
||||
@@ -1057,6 +1116,23 @@ class StoryController extends Controller
|
||||
};
|
||||
}
|
||||
|
||||
private function renderCodeBlockNode(array $attrs, array $node): string
|
||||
{
|
||||
$language = strtolower(trim((string) ($attrs['language'] ?? '')));
|
||||
$language = preg_match('/^[a-z0-9_+-]+$/', $language) === 1 ? $language : '';
|
||||
$escapedCode = e($this->extractTipTapText($node));
|
||||
|
||||
$preAttributes = $language !== ''
|
||||
? ' data-language="' . e($language) . '"'
|
||||
: '';
|
||||
|
||||
$codeAttributes = $language !== ''
|
||||
? ' class="language-' . e($language) . '" data-language="' . e($language) . '"'
|
||||
: '';
|
||||
|
||||
return '<pre' . $preAttributes . '><code' . $codeAttributes . '>' . $escapedCode . '</code></pre>';
|
||||
}
|
||||
|
||||
private function renderImageNode(array $attrs): string
|
||||
{
|
||||
$src = (string) ($attrs['src'] ?? '');
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BuddiesController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$perPage = 50;
|
||||
|
||||
try {
|
||||
$query = DB::table('friends_list as t1')
|
||||
->leftJoin('users as t2', 't1.user_id', '=', 't2.id')
|
||||
->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id')
|
||||
->where('t1.friend_id', $user->id)
|
||||
->select('t1.id', 't1.user_id', 't1.friend_id', 't2.name as uname', 'p.avatar_hash as icon', 't1.date_added')
|
||||
->orderByDesc('t1.date_added');
|
||||
|
||||
$followers = $query->paginate($perPage)->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$followers = collect();
|
||||
}
|
||||
|
||||
$page_title = ($user->name ?? $user->username ?? 'User') . ': Followers';
|
||||
|
||||
return view('user.buddies', compact('followers', 'page_title'));
|
||||
}
|
||||
}
|
||||
@@ -3,68 +3,25 @@
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\UserStatsService;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\ArtworkFavourite;
|
||||
|
||||
class FavouritesController extends Controller
|
||||
{
|
||||
public function index(Request $request, $userId = null, $username = null)
|
||||
{
|
||||
$userId = $userId ? (int) $userId : ($request->user()->id ?? null);
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$hits = 20;
|
||||
$start = ($page - 1) * $hits;
|
||||
|
||||
$total = 0;
|
||||
$results = collect();
|
||||
|
||||
try {
|
||||
$query = ArtworkFavourite::with(['artwork.user'])
|
||||
->where('user_id', $userId)
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('artwork_id');
|
||||
|
||||
$total = (int) $query->count();
|
||||
|
||||
$favorites = $query->skip($start)->take($hits)->get();
|
||||
|
||||
$results = $favorites->map(function ($fav) {
|
||||
$art = $fav->artwork;
|
||||
if (! $art) {
|
||||
return null;
|
||||
}
|
||||
$item = (object) $art->toArray();
|
||||
$item->uname = $art->user?->username ?? $art->user?->name ?? null;
|
||||
$item->datum = $fav->created_at;
|
||||
return $item;
|
||||
})->filter();
|
||||
} catch (\Throwable $e) {
|
||||
$total = 0;
|
||||
$results = collect();
|
||||
$user = $this->resolveLegacyFavouritesUser($request, $userId, $username);
|
||||
if (! $user) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$results = collect($results)->filter()->values()->transform(function ($row) {
|
||||
$row->name = $row->name ?? $row->title ?? '';
|
||||
$row->slug = $row->slug ?? Str::slug($row->name);
|
||||
$row->encoded = isset($row->id) ? app(\App\Helpers\Thumb::class)::encodeId((int) $row->id) : null;
|
||||
return $row;
|
||||
});
|
||||
|
||||
$displayName = $username ?: (DB::table('users')->where('id', $userId)->value('username') ?? '');
|
||||
$page_title = $displayName . ' Favourites';
|
||||
|
||||
return view('user.favourites', [
|
||||
'results' => $results,
|
||||
'page_title' => $page_title,
|
||||
'user_id' => $userId,
|
||||
'page' => $page,
|
||||
'hits' => $hits,
|
||||
'total' => $total,
|
||||
]);
|
||||
return redirect()->route('profile.show', [
|
||||
'username' => strtolower((string) $user->username),
|
||||
'tab' => 'favourites',
|
||||
], 301);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, $userId, $artworkId)
|
||||
@@ -82,6 +39,31 @@ class FavouritesController extends Controller
|
||||
app(UserStatsService::class)->decrementFavoritesReceived($creatorId);
|
||||
}
|
||||
|
||||
return redirect()->route('legacy.favourites', ['id' => $userId])->with('status', 'Removed from favourites');
|
||||
$username = strtolower((string) ($auth->username ?? DB::table('users')->where('id', (int) $userId)->value('username') ?? ''));
|
||||
|
||||
return redirect()->route('profile.show', [
|
||||
'username' => $username,
|
||||
'tab' => 'favourites',
|
||||
])->with('status', 'Removed from favourites');
|
||||
}
|
||||
|
||||
private function resolveLegacyFavouritesUser(Request $request, mixed $userId, mixed $username): ?User
|
||||
{
|
||||
if (is_string($userId) && ! is_numeric($userId) && $username === null) {
|
||||
$username = $userId;
|
||||
$userId = null;
|
||||
}
|
||||
|
||||
if (is_numeric($userId)) {
|
||||
return User::query()->find((int) $userId);
|
||||
}
|
||||
|
||||
if (is_string($username) && $username !== '') {
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
|
||||
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||
}
|
||||
|
||||
return $request->user();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,46 +3,54 @@
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\LegacyService;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MembersController extends Controller
|
||||
{
|
||||
protected LegacyService $legacy;
|
||||
protected ArtworkService $artworks;
|
||||
|
||||
public function __construct(LegacyService $legacy)
|
||||
public function __construct(ArtworkService $artworks)
|
||||
{
|
||||
$this->legacy = $legacy;
|
||||
$this->artworks = $artworks;
|
||||
}
|
||||
|
||||
public function photos(Request $request, $id = null)
|
||||
{
|
||||
$id = (int) ($id ?: 545);
|
||||
$artworks = $this->artworks->getArtworksByContentType('photography', 40);
|
||||
|
||||
$result = $this->legacy->categoryPage('', null, $id);
|
||||
if (! $result) {
|
||||
return redirect('/');
|
||||
}
|
||||
$artworks->getCollection()->load([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
]);
|
||||
|
||||
$page_title = $result['page_title'] ?? ($result['category']->category_name ?? 'Members Photos');
|
||||
$artworks = $result['artworks'] ?? collect();
|
||||
$artworks->getCollection()->transform(function (Artwork $artwork) {
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
|
||||
|
||||
if ($artworks && method_exists($artworks, 'getCollection')) {
|
||||
$artworks->getCollection()->transform(function ($row) {
|
||||
$row->slug = $row->slug ?? Str::slug($row->name ?? '');
|
||||
$row->thumb = $row->thumb ?? ($row->thumb_url ?? null);
|
||||
$row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null);
|
||||
return $row;
|
||||
});
|
||||
} elseif (is_iterable($artworks)) {
|
||||
$artworks = collect($artworks)->map(function ($row) {
|
||||
$row->slug = $row->slug ?? Str::slug($row->name ?? '');
|
||||
$row->thumb = $row->thumb ?? ($row->thumb_url ?? null);
|
||||
$row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null);
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
return (object) [
|
||||
'id' => $artwork->id,
|
||||
'name' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
'username' => $artwork->user->username ?? '',
|
||||
'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($artwork->user->id ?? 0), $artwork->user->profile->avatar_hash ?? null, 64),
|
||||
'content_type_name' => $primaryCategory?->contentType?->name ?? 'Photography',
|
||||
'content_type_slug' => $primaryCategory?->contentType?->slug ?? 'photography',
|
||||
'category_name' => $primaryCategory?->name ?? '',
|
||||
'category_slug' => $primaryCategory?->slug ?? '',
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'published_at' => $artwork->published_at,
|
||||
];
|
||||
});
|
||||
|
||||
$page_title = 'Member Photos';
|
||||
|
||||
return view('web.members.photos', compact('page_title', 'artworks'));
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MyBuddiesController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$perPage = 50;
|
||||
|
||||
try {
|
||||
$query = DB::table('friends_list as t1')
|
||||
->leftJoin('users as t2', 't1.friend_id', '=', 't2.id')
|
||||
->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id')
|
||||
->where('t1.user_id', $user->id)
|
||||
->select('t1.id', 't1.friend_id', 't1.user_id', 't2.name as uname', 't2.username as user_username', 'p.avatar_hash as icon', 't1.date_added')
|
||||
->orderByDesc('t1.date_added');
|
||||
|
||||
$buddies = $query->paginate($perPage)->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$buddies = collect();
|
||||
}
|
||||
|
||||
$page_title = ($user->name ?? $user->username ?? 'User') . ': Following List';
|
||||
|
||||
return view('user.mybuddies', compact('buddies', 'page_title'));
|
||||
}
|
||||
|
||||
public function destroy(Request $request, $id)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$deleted = DB::table('friends_list')->where('id', $id)->where('user_id', $user->id)->delete();
|
||||
if ($deleted) {
|
||||
$request->session()->flash('status', 'Removed from following list.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$request->session()->flash('error', 'Could not remove buddy.');
|
||||
}
|
||||
|
||||
return redirect()->route('legacy.mybuddies');
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,21 @@ use App\Http\Requests\Settings\VerifyEmailChangeRequest;
|
||||
use App\Mail\EmailChangedSecurityAlertMail;
|
||||
use App\Mail\EmailChangeVerificationCodeMail;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Country;
|
||||
use App\Models\ProfileComment;
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonInterface;
|
||||
use App\Services\Security\CaptchaVerifier;
|
||||
use App\Services\AvatarService;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\FollowService;
|
||||
use App\Services\AchievementService;
|
||||
use App\Services\LeaderboardService;
|
||||
use App\Services\Countries\CountryCatalogService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\ThumbnailService;
|
||||
use App\Services\XPService;
|
||||
use App\Services\UsernameApprovalService;
|
||||
use App\Services\UserStatsService;
|
||||
use App\Support\AvatarUrl;
|
||||
@@ -49,6 +55,10 @@ class ProfileController extends Controller
|
||||
private readonly FollowService $followService,
|
||||
private readonly UserStatsService $userStats,
|
||||
private readonly CaptchaVerifier $captchaVerifier,
|
||||
private readonly XPService $xp,
|
||||
private readonly AchievementService $achievements,
|
||||
private readonly LeaderboardService $leaderboards,
|
||||
private readonly CountryCatalogService $countryCatalog,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -74,7 +84,31 @@ class ProfileController extends Controller
|
||||
return redirect()->route('profile.show', ['username' => strtolower((string) $user->username)], 301);
|
||||
}
|
||||
|
||||
return $this->renderUserProfile($request, $user);
|
||||
return $this->renderProfilePage($request, $user);
|
||||
}
|
||||
|
||||
public function showGalleryByUsername(Request $request, string $username)
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
$user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||
|
||||
if (! $user) {
|
||||
$redirect = DB::table('username_redirects')
|
||||
->whereRaw('LOWER(old_username) = ?', [$normalized])
|
||||
->value('new_username');
|
||||
|
||||
if ($redirect) {
|
||||
return redirect()->route('profile.gallery', ['username' => strtolower((string) $redirect)], 301);
|
||||
}
|
||||
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($username !== strtolower((string) $user->username)) {
|
||||
return redirect()->route('profile.gallery', ['username' => strtolower((string) $user->username)], 301);
|
||||
}
|
||||
|
||||
return $this->renderProfilePage($request, $user, 'Profile/ProfileGallery', true);
|
||||
}
|
||||
|
||||
public function legacyById(Request $request, int $id, ?string $username = null)
|
||||
@@ -119,20 +153,27 @@ class ProfileController extends Controller
|
||||
'body' => ['required', 'string', 'min:2', 'max:2000'],
|
||||
]);
|
||||
|
||||
ProfileComment::create([
|
||||
$comment = ProfileComment::create([
|
||||
'profile_user_id' => $target->id,
|
||||
'author_user_id' => Auth::id(),
|
||||
'body' => $request->input('body'),
|
||||
]);
|
||||
|
||||
app(XPService::class)->awardCommentCreated((int) Auth::id(), (int) $comment->id, 'profile');
|
||||
|
||||
return Redirect::route('profile.show', ['username' => strtolower((string) $target->username)])
|
||||
->with('status', 'Comment posted!');
|
||||
}
|
||||
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
$user = $request->user()->loadMissing(['profile', 'country']);
|
||||
$selectedCountry = $this->countryCatalog->resolveUserCountry($user);
|
||||
|
||||
return view('profile.edit', [
|
||||
'user' => $request->user(),
|
||||
'user' => $user,
|
||||
'countries' => $this->countryCatalog->profileSelectOptions(),
|
||||
'selectedCountryId' => $selectedCountry?->id,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -141,7 +182,7 @@ class ProfileController extends Controller
|
||||
*/
|
||||
public function editSettings(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$user = $request->user()->loadMissing(['profile', 'country']);
|
||||
$cooldownDays = $this->usernameCooldownDays();
|
||||
$lastUsernameChangeAt = $this->lastUsernameChangeAt($user);
|
||||
$usernameCooldownRemainingDays = 0;
|
||||
@@ -188,15 +229,8 @@ class ProfileController extends Controller
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
// Country list
|
||||
$countries = collect();
|
||||
try {
|
||||
if (Schema::hasTable('country_list')) {
|
||||
$countries = DB::table('country_list')->orderBy('country_name')->get();
|
||||
} elseif (Schema::hasTable('countries')) {
|
||||
$countries = DB::table('countries')->orderBy('name')->get();
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
$selectedCountry = $this->countryCatalog->resolveUserCountry($user);
|
||||
$countries = $this->countryCatalog->profileSelectOptions();
|
||||
|
||||
// Avatar URL
|
||||
$avatarHash = $profileData['avatar_hash'] ?? $user->icon ?? null;
|
||||
@@ -222,7 +256,8 @@ class ProfileController extends Controller
|
||||
'description' => $user->description ?? null,
|
||||
'gender' => $user->gender ?? null,
|
||||
'birthday' => $user->birth ?? null,
|
||||
'country_code' => $user->country_code ?? null,
|
||||
'country_id' => $selectedCountry?->id ?? $user->country_id ?? null,
|
||||
'country_code' => $selectedCountry?->iso2 ?? $user->country_code ?? null,
|
||||
'email_notifications' => $emailNotifications,
|
||||
'upload_notifications' => $uploadNotifications,
|
||||
'follower_notifications' => $followerNotifications,
|
||||
@@ -238,7 +273,7 @@ class ProfileController extends Controller
|
||||
'usernameCooldownDays' => $cooldownDays,
|
||||
'usernameCooldownRemainingDays' => $usernameCooldownRemainingDays,
|
||||
'usernameCooldownActive' => $usernameCooldownRemainingDays > 0,
|
||||
'countries' => $countries->values(),
|
||||
'countries' => $countries,
|
||||
'flash' => [
|
||||
'status' => session('status'),
|
||||
'error' => session('error'),
|
||||
@@ -434,10 +469,12 @@ class ProfileController extends Controller
|
||||
public function updatePersonalSection(UpdatePersonalSectionRequest $request): RedirectResponse|JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$selectedCountry = $this->resolveCountrySelection($validated['country_id'] ?? null);
|
||||
$this->persistUserCountrySelection($request->user(), $selectedCountry);
|
||||
|
||||
$profileUpdates = [
|
||||
'birthdate' => $validated['birthday'] ?? null,
|
||||
'country_code' => $validated['country'] ?? null,
|
||||
'country_code' => $selectedCountry?->iso2,
|
||||
];
|
||||
|
||||
if (!empty($validated['gender'])) {
|
||||
@@ -513,6 +550,29 @@ class ProfileController extends Controller
|
||||
DB::table('user_profiles')->updateOrInsert(['user_id' => $userId], $filtered);
|
||||
}
|
||||
|
||||
private function resolveCountrySelection(int|string|null $countryId = null, ?string $countryCode = null): ?Country
|
||||
{
|
||||
if (is_numeric($countryId) && (int) $countryId > 0) {
|
||||
return $this->countryCatalog->findById((int) $countryId);
|
||||
}
|
||||
|
||||
if ($countryCode !== null && trim($countryCode) !== '') {
|
||||
return $this->countryCatalog->findByIso2($countryCode);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function persistUserCountrySelection(User $user, ?Country $country): void
|
||||
{
|
||||
if (! Schema::hasColumn('users', 'country_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user->country_id = $country?->id;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
private function usernameCooldownDays(): int
|
||||
{
|
||||
return max(1, (int) config('usernames.rename_cooldown_days', 30));
|
||||
@@ -655,7 +715,15 @@ class ProfileController extends Controller
|
||||
$profileUpdates['gender'] = $map[$g] ?? strtoupper($validated['gender']);
|
||||
}
|
||||
|
||||
if (!empty($validated['country'])) $profileUpdates['country_code'] = $validated['country'];
|
||||
if (array_key_exists('country_id', $validated) || array_key_exists('country', $validated)) {
|
||||
$selectedCountry = $this->resolveCountrySelection(
|
||||
$validated['country_id'] ?? null,
|
||||
$validated['country'] ?? null,
|
||||
);
|
||||
|
||||
$this->persistUserCountrySelection($user, $selectedCountry);
|
||||
$profileUpdates['country_code'] = $selectedCountry?->iso2;
|
||||
}
|
||||
|
||||
if (array_key_exists('mailing', $validated)) {
|
||||
$profileUpdates['mlist'] = filter_var($validated['mailing'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
|
||||
@@ -768,7 +836,7 @@ class ProfileController extends Controller
|
||||
return Redirect::route('dashboard.profile')->with('status', 'password-updated');
|
||||
}
|
||||
|
||||
private function renderUserProfile(Request $request, User $user)
|
||||
private function renderProfilePage(Request $request, User $user, string $component = 'Profile/ProfileShow', bool $galleryOnly = false)
|
||||
{
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
$viewer = Auth::user();
|
||||
@@ -777,21 +845,7 @@ class ProfileController extends Controller
|
||||
// ── Artworks (cursor-paginated) ──────────────────────────────────────
|
||||
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage)
|
||||
->through(function (Artwork $art) {
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
return (object) [
|
||||
'id' => $art->id,
|
||||
'name' => $art->title,
|
||||
'picture' => $art->file_name,
|
||||
'datum' => $art->published_at,
|
||||
'published_at' => $art->published_at, // required by cursor paginator (orders by this column)
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $art->user->name ?? 'Skinbase',
|
||||
'username' => $art->user->username ?? null,
|
||||
'user_id' => $art->user_id,
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
];
|
||||
return (object) $this->mapArtworkCardPayload($art);
|
||||
});
|
||||
|
||||
// ── Featured artworks for this user ─────────────────────────────────
|
||||
@@ -829,27 +883,42 @@ class ProfileController extends Controller
|
||||
}
|
||||
|
||||
// ── Favourites ───────────────────────────────────────────────────────
|
||||
$favourites = collect();
|
||||
if (Schema::hasTable('user_favorites')) {
|
||||
$favIds = DB::table('user_favorites as uf')
|
||||
->join('artworks as a', 'a.id', '=', 'uf.artwork_id')
|
||||
->where('uf.user_id', $user->id)
|
||||
$favouriteLimit = 12;
|
||||
$favouriteTable = $this->resolveFavouriteTable();
|
||||
$favourites = [
|
||||
'data' => [],
|
||||
'next_cursor' => null,
|
||||
];
|
||||
if ($favouriteTable !== null) {
|
||||
$favIds = DB::table($favouriteTable . ' as af')
|
||||
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
|
||||
->where('af.user_id', $user->id)
|
||||
->whereNull('a.deleted_at')
|
||||
->where('a.is_public', true)
|
||||
->where('a.is_approved', true)
|
||||
->orderByDesc('uf.created_at')
|
||||
->limit(12)
|
||||
->whereNotNull('a.published_at')
|
||||
->orderByDesc('af.created_at')
|
||||
->orderByDesc('af.artwork_id')
|
||||
->limit($favouriteLimit + 1)
|
||||
->pluck('a.id');
|
||||
|
||||
if ($favIds->isNotEmpty()) {
|
||||
$hasMore = $favIds->count() > $favouriteLimit;
|
||||
$favIds = $favIds->take($favouriteLimit);
|
||||
|
||||
$indexed = Artwork::with('user:id,name,username')
|
||||
->whereIn('id', $favIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
// Preserve the ordering from the favourites table
|
||||
$favourites = $favIds
|
||||
|
||||
$favourites = [
|
||||
'data' => $favIds
|
||||
->filter(fn ($id) => $indexed->has($id))
|
||||
->map(fn ($id) => $indexed[$id]);
|
||||
->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id]))
|
||||
->values()
|
||||
->all(),
|
||||
'next_cursor' => $hasMore ? base64_encode((string) $favouriteLimit) : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -916,6 +985,7 @@ class ProfileController extends Controller
|
||||
->select([
|
||||
'pc.id', 'pc.body', 'pc.created_at',
|
||||
'u.id as author_id', 'u.username as author_username', 'u.name as author_name',
|
||||
'u.level as author_level', 'u.rank as author_rank',
|
||||
'up.avatar_hash as author_avatar_hash', 'up.signature as author_signature',
|
||||
])
|
||||
->get()
|
||||
@@ -925,12 +995,16 @@ class ProfileController extends Controller
|
||||
'created_at' => $row->created_at,
|
||||
'author_id' => $row->author_id,
|
||||
'author_name' => $row->author_username ?? $row->author_name ?? 'Unknown',
|
||||
'author_level' => (int) ($row->author_level ?? 1),
|
||||
'author_rank' => (string) ($row->author_rank ?? 'Newbie'),
|
||||
'author_profile_url' => '/@' . strtolower((string) ($row->author_username ?? $row->author_id)),
|
||||
'author_avatar' => AvatarUrl::forUser((int) $row->author_id, $row->author_avatar_hash, 50),
|
||||
'author_signature' => $row->author_signature,
|
||||
]);
|
||||
}
|
||||
|
||||
$xpSummary = $this->xp->summary((int) $user->id);
|
||||
|
||||
$creatorStories = Story::query()
|
||||
->published()
|
||||
->with(['tags'])
|
||||
@@ -959,21 +1033,19 @@ class ProfileController extends Controller
|
||||
'views' => (int) $story->views,
|
||||
'likes_count' => (int) $story->likes_count,
|
||||
'comments_count' => (int) $story->comments_count,
|
||||
'creator_level' => $xpSummary['level'],
|
||||
'creator_rank' => $xpSummary['rank'],
|
||||
'published_at' => $story->published_at?->toISOString(),
|
||||
]);
|
||||
|
||||
// ── Profile data ─────────────────────────────────────────────────────
|
||||
$profile = $user->profile;
|
||||
$country = $this->countryCatalog->resolveUserCountry($user);
|
||||
$countryCode = $country?->iso2 ?? $profile?->country_code;
|
||||
$countryName = $country?->name_common;
|
||||
|
||||
// ── Country name (from old country_list table if available) ──────────
|
||||
$countryName = null;
|
||||
if ($profile?->country_code) {
|
||||
if (Schema::hasTable('country_list')) {
|
||||
$countryName = DB::table('country_list')
|
||||
->where('country_code', $profile->country_code)
|
||||
->value('country_name');
|
||||
}
|
||||
$countryName = $countryName ?? strtoupper((string) $profile->country_code);
|
||||
if ($countryName === null && $profile?->country_code) {
|
||||
$countryName = strtoupper((string) $profile->country_code);
|
||||
}
|
||||
|
||||
// ── Cover image hero (preferred) ────────────────────────────────────
|
||||
@@ -1013,9 +1085,13 @@ class ProfileController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
$canonical = url('/@' . strtolower((string) ($user->username ?? '')));
|
||||
$usernameSlug = strtolower((string) ($user->username ?? ''));
|
||||
$canonical = url('/@' . $usernameSlug);
|
||||
$galleryUrl = url('/@' . $usernameSlug . '/gallery');
|
||||
$achievementSummary = $this->achievements->summary((int) $user->id);
|
||||
$leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id);
|
||||
|
||||
return Inertia::render('Profile/ProfileShow', [
|
||||
return Inertia::render($component, [
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'username' => $user->username,
|
||||
@@ -1025,18 +1101,25 @@ class ProfileController extends Controller
|
||||
'cover_position'=> (int) ($user->cover_position ?? 50),
|
||||
'created_at' => $user->created_at?->toISOString(),
|
||||
'last_visit_at' => $user->last_visit_at ? (string) $user->last_visit_at : null,
|
||||
'xp' => $xpSummary['xp'],
|
||||
'level' => $xpSummary['level'],
|
||||
'rank' => $xpSummary['rank'],
|
||||
'next_level_xp' => $xpSummary['next_level_xp'],
|
||||
'current_level_xp' => $xpSummary['current_level_xp'],
|
||||
'progress_percent' => $xpSummary['progress_percent'],
|
||||
'max_level' => $xpSummary['max_level'],
|
||||
],
|
||||
'profile' => $profile ? [
|
||||
'about' => $profile->about ?? null,
|
||||
'website' => $profile->website ?? null,
|
||||
'country_code' => $profile->country_code ?? null,
|
||||
'country_code' => $countryCode,
|
||||
'gender' => $profile->gender ?? null,
|
||||
'birthdate' => $profile->birthdate ?? null,
|
||||
'cover_image' => $profile->cover_image ?? null,
|
||||
] : null,
|
||||
'artworks' => $artworkPayload,
|
||||
'featuredArtworks' => $featuredArtworks->values(),
|
||||
'favourites' => $favourites->values(),
|
||||
'favourites' => $favourites,
|
||||
'stats' => $stats,
|
||||
'socialLinks' => $socialLinks,
|
||||
'followerCount' => $followerCount,
|
||||
@@ -1045,14 +1128,71 @@ class ProfileController extends Controller
|
||||
'heroBgUrl' => $heroBgUrl,
|
||||
'profileComments' => $profileComments->values(),
|
||||
'creatorStories' => $creatorStories->values(),
|
||||
'achievements' => $achievementSummary,
|
||||
'leaderboardRank' => $leaderboardRank,
|
||||
'countryName' => $countryName,
|
||||
'isOwner' => $isOwner,
|
||||
'auth' => $authData,
|
||||
'profileUrl' => $canonical,
|
||||
'galleryUrl' => $galleryUrl,
|
||||
])->withViewData([
|
||||
'page_title' => ($user->username ?? $user->name ?? 'User') . ' on Skinbase',
|
||||
'page_canonical' => $canonical,
|
||||
'page_meta_description' => 'View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.',
|
||||
'page_title' => $galleryOnly
|
||||
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
|
||||
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase'),
|
||||
'page_canonical' => $galleryOnly ? $galleryUrl : $canonical,
|
||||
'page_meta_description' => $galleryOnly
|
||||
? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.')
|
||||
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.'),
|
||||
'og_image' => $avatarUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveFavouriteTable(): ?string
|
||||
{
|
||||
foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) {
|
||||
if (Schema::hasTable($table)) {
|
||||
return $table;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapArtworkCardPayload(Artwork $art): array
|
||||
{
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
|
||||
return [
|
||||
'id' => $art->id,
|
||||
'name' => $art->title,
|
||||
'picture' => $art->file_name,
|
||||
'datum' => $this->formatIsoDate($art->published_at),
|
||||
'published_at' => $this->formatIsoDate($art->published_at),
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $art->user->name ?? 'Skinbase',
|
||||
'username' => $art->user->username ?? null,
|
||||
'user_id' => $art->user_id,
|
||||
'author_level' => (int) ($art->user?->level ?? 1),
|
||||
'author_rank' => (string) ($art->user?->rank ?? 'Newbie'),
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
];
|
||||
}
|
||||
|
||||
private function formatIsoDate(mixed $value): ?string
|
||||
{
|
||||
if ($value instanceof CarbonInterface) {
|
||||
return $value->toISOString();
|
||||
}
|
||||
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format(DATE_ATOM);
|
||||
}
|
||||
|
||||
return is_string($value) ? $value : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Http\Controllers\User;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\ArtworkDownload;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
|
||||
@@ -17,7 +16,11 @@ class TodayDownloadsController extends Controller
|
||||
|
||||
$today = Carbon::now()->toDateString();
|
||||
|
||||
$query = ArtworkDownload::with(['artwork'])
|
||||
$query = ArtworkDownload::with([
|
||||
'artwork.user:id,name,username',
|
||||
'artwork.user.profile:user_id,avatar_hash',
|
||||
'artwork.categories:id,name,slug',
|
||||
])
|
||||
->whereDate('created_at', $today)
|
||||
->whereHas('artwork', function ($q) {
|
||||
$q->public()->published()->whereNull('deleted_at');
|
||||
@@ -34,13 +37,31 @@ class TodayDownloadsController extends Controller
|
||||
$art = \App\Models\Artwork::find($row->artwork_id);
|
||||
}
|
||||
|
||||
if (! $art) {
|
||||
return (object) [
|
||||
'id' => null,
|
||||
'name' => 'Artwork',
|
||||
'slug' => 'artwork',
|
||||
'thumb' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'thumb_url' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'thumb_srcset' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'category_name' => '',
|
||||
'category_slug' => '',
|
||||
'num_downloads' => $row->num_downloads ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
$name = $art->title ?? null;
|
||||
$picture = $art->file_name ?? null;
|
||||
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$encoded = null;
|
||||
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$categoryId = $art->categories->first()->id ?? null;
|
||||
$primaryCategory = $art->categories->first();
|
||||
$categoryId = $primaryCategory->id ?? null;
|
||||
$categoryName = $primaryCategory->name ?? '';
|
||||
$categorySlug = $primaryCategory->slug ?? '';
|
||||
$avatarHash = $art->user->profile->avatar_hash ?? null;
|
||||
|
||||
return (object) [
|
||||
'id' => $art->id ?? null,
|
||||
@@ -50,8 +71,17 @@ class TodayDownloadsController extends Controller
|
||||
'ext' => $ext,
|
||||
'encoded' => $encoded,
|
||||
'thumb' => $thumb,
|
||||
'thumb_url' => $thumb,
|
||||
'thumb_srcset' => $thumb,
|
||||
'category' => $categoryId,
|
||||
'category_name' => $categoryName,
|
||||
'category_slug' => $categorySlug,
|
||||
'uname' => $art->user->name ?? 'Skinbase',
|
||||
'username' => $art->user->username ?? '',
|
||||
'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($art->user->id ?? 0), $avatarHash, 64),
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
'published_at' => $art->published_at,
|
||||
'num_downloads' => $row->num_downloads ?? 0,
|
||||
'gid_num' => $categoryId ? ((int) $categoryId % 5) * 5 : 0,
|
||||
];
|
||||
|
||||
@@ -264,35 +264,6 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function legacyCategory(Request $request, ?string $group = null, ?string $slug = null, ?string $id = null)
|
||||
{
|
||||
if ($id !== null && ctype_digit((string) $id)) {
|
||||
$category = Category::with('contentType')->find((int) $id);
|
||||
if (! $category || ! $category->contentType) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return redirect($category->url, 301);
|
||||
}
|
||||
|
||||
$contentSlug = strtolower((string) $group);
|
||||
if (! in_array($contentSlug, self::CONTENT_TYPE_SLUGS, true)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$target = '/' . $contentSlug;
|
||||
$normalizedSlug = trim((string) $slug, '/');
|
||||
if ($normalizedSlug !== '') {
|
||||
$target .= '/' . strtolower($normalizedSlug);
|
||||
}
|
||||
|
||||
if ($request->query()) {
|
||||
$target .= '?' . http_build_query($request->query());
|
||||
}
|
||||
|
||||
return redirect($target, 301);
|
||||
}
|
||||
|
||||
private function presentArtwork(Artwork $artwork): object
|
||||
{
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
|
||||
@@ -45,6 +45,10 @@ final class CommunityActivityController extends Controller
|
||||
|
||||
private function resolveFilter(Request $request): string
|
||||
{
|
||||
if ($request->filled('type') && ! $request->filled('filter')) {
|
||||
return (string) $request->query('type', 'all');
|
||||
}
|
||||
|
||||
if ($request->boolean('following') && ! $request->filled('filter')) {
|
||||
return 'following';
|
||||
}
|
||||
|
||||
@@ -4,51 +4,23 @@ namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use App\Support\UsernamePolicy;
|
||||
|
||||
class GalleryController extends Controller
|
||||
{
|
||||
public function show(Request $request, $userId, $username = null)
|
||||
{
|
||||
$user = User::find((int)$userId);
|
||||
$user = User::find((int) $userId);
|
||||
if (! $user) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// canonicalize username in URL when possible
|
||||
try {
|
||||
$correctName = $user->name ?? $user->uname ?? null;
|
||||
if ($username && $correctName && $username !== $correctName) {
|
||||
$qs = $request->getQueryString();
|
||||
$url = route('legacy.gallery', ['id' => $user->id, 'username' => $correctName]);
|
||||
if ($qs) $url .= '?' . $qs;
|
||||
return redirect($url, 301);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
$usernameSlug = UsernamePolicy::normalize((string) ($user->username ?? $user->name ?? ''));
|
||||
if ($usernameSlug === '') {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$hits = 20;
|
||||
|
||||
$query = Artwork::where('user_id', $user->id)
|
||||
->approved()
|
||||
->published()
|
||||
->public()
|
||||
->orderByDesc('published_at');
|
||||
|
||||
$total = (int) $query->count();
|
||||
|
||||
$artworks = $query->skip(($page - 1) * $hits)->take($hits)->get();
|
||||
|
||||
return view('web.gallery', [
|
||||
'user' => $user,
|
||||
'artworks' => $artworks,
|
||||
'page' => $page,
|
||||
'hits' => $hits,
|
||||
'total' => $total,
|
||||
]);
|
||||
return redirect()->route('profile.gallery', ['username' => $usernameSlug], 301);
|
||||
}
|
||||
}
|
||||
|
||||
35
app/Http/Controllers/Web/LeaderboardPageController.php
Normal file
35
app/Http/Controllers/Web/LeaderboardPageController.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Leaderboard;
|
||||
use App\Services\LeaderboardService;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class LeaderboardPageController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, LeaderboardService $leaderboards): Response
|
||||
{
|
||||
$period = $leaderboards->normalizePeriod((string) $request->query('period', 'weekly'));
|
||||
$type = match ((string) $request->query('type', 'creators')) {
|
||||
'artworks', Leaderboard::TYPE_ARTWORK => Leaderboard::TYPE_ARTWORK,
|
||||
'stories', Leaderboard::TYPE_STORY => Leaderboard::TYPE_STORY,
|
||||
default => Leaderboard::TYPE_CREATOR,
|
||||
};
|
||||
|
||||
return Inertia::render('Leaderboard/LeaderboardPage', [
|
||||
'initialType' => $type,
|
||||
'initialPeriod' => $period,
|
||||
'initialData' => $leaderboards->getLeaderboard($type, $period),
|
||||
'meta' => [
|
||||
'title' => 'Top Creators & Artworks Leaderboard | Skinbase',
|
||||
'description' => 'Track the leading creators, artworks, and stories across Skinbase by daily, weekly, monthly, and all-time performance.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user