This commit is contained in:
2026-03-20 21:17:26 +01:00
parent 1a62fcb81d
commit 29c3ff8572
229 changed files with 13147 additions and 2577 deletions

View File

@@ -47,21 +47,73 @@ final class AiTagArtworksCommand extends Command
// Prompt // Prompt
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
private const SYSTEM_PROMPT = <<<'PROMPT' private const SYSTEM_PROMPT = <<<'PROMPT'
You are an expert at analysing visual artwork and generating concise, descriptive tags. You are a precise visual-art tagging engine for an artwork gallery.
Your task is to analyse an artwork image and generate high-quality search tags that are useful for discovery, filtering, and categorisation.
Prioritise tags that are:
- visually evident in the image
- concise and specific
- useful for gallery search
Prefer concrete visual concepts over vague opinions.
Do not invent details that are not clearly visible.
Do not include artist names, brands, watermarks, or assumptions about intent unless directly visible.
Return tags that describe:
- subject or scene
- art style or genre
- mood or atmosphere
- colour palette
- technique or medium if visually apparent
- composition or notable visual elements if relevant
Avoid:
- generic filler tags like "beautiful", "nice", "art", "image"
- duplicate or near-duplicate tags
- full sentences
- overly broad tags when a more specific one is visible
Output must be deterministic, compact, and consistent.
PROMPT; PROMPT;
private const USER_PROMPT = <<<'PROMPT' private const USER_PROMPT = <<<'PROMPT'
Analyse the artwork image and return a JSON array of relevant tags. Analyse this artwork image and return a JSON array of relevant tags.
Cover: art style, subject/theme, dominant colours, mood, technique, and medium where visible.
Rules: Requirements:
- Return ONLY a valid JSON array of lowercase strings no markdown, no explanation. - Return ONLY a valid JSON array of lowercase strings.
- Each tag must be 14 words, no punctuation except hyphens. - No markdown, no explanation, no extra text.
- Between 6 and 12 tags total. - Output between 8 and 14 tags.
- Each tag must be 1 to 3 words.
- Use only letters, numbers, spaces, and hyphens.
- Do not end tags with punctuation.
- Do not include duplicate or near-duplicate tags.
- Order tags from most important to least important.
Example output: Focus on tags from these groups when visible:
["digital painting","fantasy","portrait","dark tones","glowing eyes","detailed","dramatic lighting"] 1. main subject or scene
2. style or genre
3. mood or atmosphere
4. dominant colours
5. medium or technique
6. notable visual elements or composition
Tagging guidelines:
- Prefer specific tags over generic ones.
- Use searchable gallery-style tags.
- Include only what is clearly visible or strongly implied by the image.
- If the artwork is abstract, prioritise style, colour, mood, and composition.
- If the artwork is representational, prioritise subject, setting, style, and mood.
- If a detail is uncertain, leave it out.
Good output example:
["fantasy portrait","digital painting","female warrior","blue tones","dramatic lighting","glowing eyes","cinematic mood","detailed armor"]
Bad output example:
["art","beautiful image","very cool fantasy woman","amazing colors","masterpiece"]
Now return only the JSON array.
PROMPT; PROMPT;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -49,7 +49,7 @@ class AvatarsMigrate extends Command
* *
* @var int[] * @var int[]
*/ */
protected $sizes = [32, 40, 64, 128, 256, 512]; protected $sizes = [32, 40, 64, 80, 96, 128, 256, 512];
public function handle(): int public function handle(): int
{ {

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\AchievementService;
use App\Services\XPService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RecalculateUserXpCommand extends Command
{
protected $signature = 'skinbase:recalculate-user-xp
{user_id? : The ID of a single user to recompute}
{--all : Recompute XP and level for all non-deleted users}
{--chunk=1000 : Chunk size when --all is used}
{--dry-run : Show computed values without writing}
{--sync-achievements : Re-run achievement checks after a live recalculation}';
protected $description = 'Rebuild stored user XP, level, and rank from user_xp_logs';
public function handle(XPService $xp, AchievementService $achievements): int
{
$userId = $this->argument('user_id');
$all = (bool) $this->option('all');
$dryRun = (bool) $this->option('dry-run');
$syncAchievements = (bool) $this->option('sync-achievements');
$chunk = max(1, (int) $this->option('chunk'));
if ($userId !== null && $all) {
$this->error('Provide either a user_id or --all, not both.');
return self::FAILURE;
}
if ($userId !== null) {
return $this->recalculateSingle((int) $userId, $xp, $achievements, $dryRun, $syncAchievements);
}
if ($all) {
return $this->recalculateAll($xp, $achievements, $chunk, $dryRun, $syncAchievements);
}
$this->error('Provide a user_id or use --all.');
return self::FAILURE;
}
private function recalculateSingle(
int $userId,
XPService $xp,
AchievementService $achievements,
bool $dryRun,
bool $syncAchievements,
): int {
$exists = DB::table('users')->where('id', $userId)->exists();
if (! $exists) {
$this->error("User {$userId} not found.");
return self::FAILURE;
}
$label = $dryRun ? '[DRY-RUN]' : '[LIVE]';
$this->line("{$label} Recomputing XP for user #{$userId}...");
$result = $xp->recalculateStoredProgress($userId, ! $dryRun);
$this->table(
['Field', 'Stored', 'Computed'],
[
['xp', $result['previous']['xp'], $result['computed']['xp']],
['level', $result['previous']['level'], $result['computed']['level']],
['rank', $result['previous']['rank'], $result['computed']['rank']],
]
);
if ($dryRun) {
if ($syncAchievements) {
$pending = $achievements->previewUnlocks($userId);
$this->line('Achievements preview: ' . (empty($pending) ? 'no pending unlocks' : implode(', ', $pending)));
}
$this->warn('Dry-run: no changes written.');
return self::SUCCESS;
}
if ($syncAchievements) {
$unlocked = $achievements->checkAchievements($userId);
$this->line('Achievements checked: ' . (empty($unlocked) ? 'no new unlocks' : implode(', ', $unlocked)));
}
$this->info($result['changed'] ? "XP updated for user #{$userId}." : "User #{$userId} was already in sync.");
return self::SUCCESS;
}
private function recalculateAll(
XPService $xp,
AchievementService $achievements,
int $chunk,
bool $dryRun,
bool $syncAchievements,
): int {
$total = DB::table('users')->whereNull('deleted_at')->count();
$label = $dryRun ? '[DRY-RUN]' : '[LIVE]';
$this->info("{$label} Recomputing XP for {$total} users (chunk={$chunk})...");
$processed = 0;
$changed = 0;
$pendingAchievementUsers = 0;
$pendingAchievementUnlocks = 0;
$appliedAchievementUnlocks = 0;
$bar = $this->output->createProgressBar($total);
$bar->start();
DB::table('users')
->whereNull('deleted_at')
->orderBy('id')
->chunkById($chunk, function ($users) use ($xp, $achievements, $dryRun, $syncAchievements, &$processed, &$changed, &$pendingAchievementUsers, &$pendingAchievementUnlocks, &$appliedAchievementUnlocks, $bar): void {
foreach ($users as $user) {
$result = $xp->recalculateStoredProgress((int) $user->id, ! $dryRun);
if ($result['changed']) {
$changed++;
}
if ($syncAchievements) {
if ($dryRun) {
$pending = $achievements->previewUnlocks((int) $user->id);
if (! empty($pending)) {
$pendingAchievementUsers++;
$pendingAchievementUnlocks += count($pending);
}
} else {
$unlocked = $achievements->checkAchievements((int) $user->id);
$appliedAchievementUnlocks += count($unlocked);
}
}
$processed++;
$bar->advance();
}
});
$bar->finish();
$this->newLine();
$summary = "Done - {$processed} users processed, {$changed} " . ($dryRun ? 'would change.' : 'updated.');
if ($syncAchievements) {
if ($dryRun) {
$summary .= " Achievement preview: {$pendingAchievementUnlocks} pending unlock(s) across {$pendingAchievementUsers} user(s).";
} else {
$summary .= " Achievements re-checked: {$appliedAchievementUnlocks} unlock(s) applied.";
}
}
$this->info($summary);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Countries\CountrySyncService;
use Illuminate\Console\Command;
use Throwable;
final class SyncCountriesCommand extends Command
{
protected $signature = 'skinbase:sync-countries
{--deactivate-missing : Mark countries missing from the source as inactive}
{--no-fallback : Fail instead of using the local fallback dataset when remote fetch fails}';
protected $description = 'Synchronize ISO 3166 country metadata into the local countries table.';
public function __construct(
private readonly CountrySyncService $countrySyncService,
) {
parent::__construct();
}
public function handle(): int
{
try {
$summary = $this->countrySyncService->sync(
allowFallback: ! (bool) $this->option('no-fallback'),
deactivateMissing: (bool) $this->option('deactivate-missing') ? true : null,
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->info('Countries synchronized successfully.');
$this->line('Source: '.($summary['source'] ?? 'unknown'));
$this->line('Fetched: '.(int) ($summary['total_fetched'] ?? 0));
$this->line('Inserted: '.(int) ($summary['inserted'] ?? 0));
$this->line('Updated: '.(int) ($summary['updated'] ?? 0));
$this->line('Skipped: '.(int) ($summary['skipped'] ?? 0));
$this->line('Invalid: '.(int) ($summary['invalid'] ?? 0));
$this->line('Deactivated: '.(int) ($summary['deactivated'] ?? 0));
$this->line('Backfilled users: '.(int) ($summary['backfilled_users'] ?? 0));
return self::SUCCESS;
}
}

View File

@@ -13,11 +13,13 @@ use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
use App\Console\Commands\SeedTagInteractionDemoCommand; use App\Console\Commands\SeedTagInteractionDemoCommand;
use App\Console\Commands\EvaluateFeedWeightsCommand; use App\Console\Commands\EvaluateFeedWeightsCommand;
use App\Console\Commands\AiTagArtworksCommand; use App\Console\Commands\AiTagArtworksCommand;
use App\Console\Commands\SyncCountriesCommand;
use App\Console\Commands\CompareFeedAbCommand; use App\Console\Commands\CompareFeedAbCommand;
use App\Console\Commands\RecalculateTrendingCommand; use App\Console\Commands\RecalculateTrendingCommand;
use App\Console\Commands\RecalculateRankingsCommand; use App\Console\Commands\RecalculateRankingsCommand;
use App\Console\Commands\MetricsSnapshotHourlyCommand; use App\Console\Commands\MetricsSnapshotHourlyCommand;
use App\Console\Commands\RecalculateHeatCommand; use App\Console\Commands\RecalculateHeatCommand;
use App\Jobs\UpdateLeaderboardsJob;
use App\Jobs\RankComputeArtworkScoresJob; use App\Jobs\RankComputeArtworkScoresJob;
use App\Jobs\RankBuildListsJob; use App\Jobs\RankBuildListsJob;
use App\Uploads\Commands\CleanupUploadsCommand; use App\Uploads\Commands\CleanupUploadsCommand;
@@ -48,6 +50,7 @@ class Kernel extends ConsoleKernel
EvaluateFeedWeightsCommand::class, EvaluateFeedWeightsCommand::class,
CompareFeedAbCommand::class, CompareFeedAbCommand::class,
AiTagArtworksCommand::class, AiTagArtworksCommand::class,
SyncCountriesCommand::class,
\App\Console\Commands\MigrateFollows::class, \App\Console\Commands\MigrateFollows::class,
RecalculateTrendingCommand::class, RecalculateTrendingCommand::class,
RecalculateRankingsCommand::class, RecalculateRankingsCommand::class,
@@ -88,6 +91,12 @@ class Kernel extends ConsoleKernel
->withoutOverlapping() ->withoutOverlapping()
->runInBackground(); ->runInBackground();
$schedule->job(new UpdateLeaderboardsJob)
->hourlyAt(20)
->name('leaderboards-refresh')
->withoutOverlapping()
->runInBackground();
// ── Rising Engine (Heat / Momentum) ───────────────────────────────── // ── Rising Engine (Heat / Momentum) ─────────────────────────────────
// Step 1: snapshot metric totals every hour at :00 // Step 1: snapshot metric totals every hour at :00
$schedule->command('nova:metrics-snapshot-hourly') $schedule->command('nova:metrics-snapshot-hourly')
@@ -104,6 +113,12 @@ class Kernel extends ConsoleKernel
// Step 3: prune old snapshots daily at 04:00 // Step 3: prune old snapshots daily at 04:00
$schedule->command('nova:prune-metric-snapshots --keep-days=7') $schedule->command('nova:prune-metric-snapshots --keep-days=7')
->dailyAt('04:00'); ->dailyAt('04:00');
$schedule->command('skinbase:sync-countries')
->monthlyOn(1, '03:40')
->name('sync-countries')
->withoutOverlapping()
->runInBackground();
} }
/** /**

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Events\Achievements;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class AchievementCheckRequested
{
use Dispatchable, SerializesModels;
public function __construct(public readonly int $userId) {}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Events\Achievements;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserXpUpdated
{
use Dispatchable, SerializesModels;
public function __construct(public readonly int $userId) {}
}

View 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);
}
}

View File

@@ -9,6 +9,7 @@ use App\Models\Story;
use App\Models\StoryTag; use App\Models\StoryTag;
use App\Models\User; use App\Models\User;
use App\Notifications\StoryStatusNotification; use App\Notifications\StoryStatusNotification;
use App\Services\StoryPublicationService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -78,6 +79,10 @@ class StoryAdminController extends Controller
$story->tags()->sync($validated['tags']); $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]) return redirect()->route('admin.stories.edit', ['story' => $story->id])
->with('status', 'Story created.'); ->with('status', 'Story created.');
} }
@@ -95,6 +100,8 @@ class StoryAdminController extends Controller
public function update(Request $request, Story $story): RedirectResponse public function update(Request $request, Story $story): RedirectResponse
{ {
$wasPublished = $story->published_at !== null || $story->status === 'published';
$validated = $request->validate([ $validated = $request->validate([
'creator_id' => ['required', 'integer', 'exists:users,id'], 'creator_id' => ['required', 'integer', 'exists:users,id'],
'title' => ['required', 'string', 'max:255'], 'title' => ['required', 'string', 'max:255'],
@@ -122,6 +129,10 @@ class StoryAdminController extends Controller
$story->tags()->sync($validated['tags'] ?? []); $story->tags()->sync($validated['tags'] ?? []);
if (! $wasPublished && $validated['status'] === 'published') {
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
}
return back()->with('status', 'Story updated.'); return back()->with('status', 'Story updated.');
} }
@@ -134,14 +145,11 @@ class StoryAdminController extends Controller
public function publish(Story $story): RedirectResponse public function publish(Story $story): RedirectResponse
{ {
$story->update([ app(StoryPublicationService::class)->publish($story, 'published', [
'status' => 'published',
'published_at' => $story->published_at ?? now(), 'published_at' => $story->published_at ?? now(),
'reviewed_at' => now(), 'reviewed_at' => now(),
]); ]);
$story->creator?->notify(new StoryStatusNotification($story, 'published'));
return back()->with('status', 'Story published.'); return back()->with('status', 'Story published.');
} }
@@ -154,16 +162,13 @@ class StoryAdminController extends Controller
public function approve(Request $request, Story $story): RedirectResponse public function approve(Request $request, Story $story): RedirectResponse
{ {
$story->update([ app(StoryPublicationService::class)->publish($story, 'approved', [
'status' => 'published',
'published_at' => $story->published_at ?? now(), 'published_at' => $story->published_at ?? now(),
'reviewed_at' => now(), 'reviewed_at' => now(),
'reviewed_by_id' => (int) $request->user()->id, 'reviewed_by_id' => (int) $request->user()->id,
'rejected_reason' => null, 'rejected_reason' => null,
]); ]);
$story->creator?->notify(new StoryStatusNotification($story, 'approved'));
return back()->with('status', 'Story approved and published.'); return back()->with('status', 'Story approved and published.');
} }

View File

@@ -5,8 +5,11 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\ArtworkComment; 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\ContentSanitizer;
use App\Services\LegacySmileyMapper;
use App\Support\AvatarUrl; use App\Support\AvatarUrl;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -113,6 +116,7 @@ class ArtworkCommentController extends Controller
Cache::forget('comments.latest.all.page1'); Cache::forget('comments.latest.all.page1');
$comment->load(['user', 'user.profile']); $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) // Record activity event (fire-and-forget; never break the response)
try { try {
@@ -204,6 +208,8 @@ class ArtworkCommentController extends Controller
'display' => $user?->username ?? $user?->name ?? 'User', 'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId, 'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64), '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; 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));
});
}
} }

View File

@@ -4,9 +4,13 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Events\Achievements\AchievementCheckRequested;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Notifications\ArtworkLikedNotification;
use App\Services\FollowService; use App\Services\FollowService;
use App\Services\UserStatsService; use App\Services\UserStatsService;
use App\Services\XPService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -14,11 +18,25 @@ use Illuminate\Support\Facades\Schema;
final class ArtworkInteractionController extends Controller 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 public function favorite(Request $request, int $artworkId): JsonResponse
{ {
$state = $request->boolean('state', true); $state = $request->boolean('state', true);
$this->toggleSimple( $changed = $this->toggleSimple(
request: $request, request: $request,
table: 'artwork_favourites', table: 'artwork_favourites',
keyColumns: ['user_id', 'artwork_id'], 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'); $creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
if ($creatorId) { if ($creatorId) {
$svc = app(UserStatsService::class); $svc = app(UserStatsService::class);
if ($state) { if ($state && $changed) {
$svc->incrementFavoritesReceived($creatorId); $svc->incrementFavoritesReceived($creatorId);
$svc->setLastActiveAt((int) $request->user()->id); $svc->setLastActiveAt((int) $request->user()->id);
@@ -46,7 +64,7 @@ final class ArtworkInteractionController extends Controller
targetId: $artworkId, targetId: $artworkId,
); );
} catch (\Throwable) {} } catch (\Throwable) {}
} else { } elseif (! $state && $changed) {
$svc->decrementFavoritesReceived($creatorId); $svc->decrementFavoritesReceived($creatorId);
} }
} }
@@ -56,7 +74,7 @@ final class ArtworkInteractionController extends Controller
public function like(Request $request, int $artworkId): JsonResponse public function like(Request $request, int $artworkId): JsonResponse
{ {
$this->toggleSimple( $changed = $this->toggleSimple(
request: $request, request: $request,
table: 'artwork_likes', table: 'artwork_likes',
keyColumns: ['user_id', 'artwork_id'], keyColumns: ['user_id', 'artwork_id'],
@@ -67,6 +85,20 @@ final class ArtworkInteractionController extends Controller
$this->syncArtworkStats($artworkId); $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)); 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); return response()->json(['message' => 'Cannot follow yourself'], 422);
} }
$svc = app(FollowService::class); $svc = app(FollowService::class);
$state = $request->boolean('state', true); $state = $request->has('state')
? $request->boolean('state')
: ! $request->isMethod('delete');
if ($state) { if ($state) {
$svc->follow($actorId, $userId); $svc->follow($actorId, $userId);
@@ -148,7 +182,7 @@ final class ArtworkInteractionController extends Controller
array $keyValues, array $keyValues,
array $insertPayload, array $insertPayload,
string $requiredTable string $requiredTable
): void { ): bool {
if (! Schema::hasTable($requiredTable)) { if (! Schema::hasTable($requiredTable)) {
abort(422, 'Interaction unavailable'); abort(422, 'Interaction unavailable');
} }
@@ -163,10 +197,13 @@ final class ArtworkInteractionController extends Controller
if ($state) { if ($state) {
if (! $query->exists()) { if (! $query->exists()) {
DB::table($table)->insert(array_merge($keyValues, $insertPayload)); DB::table($table)->insert(array_merge($keyValues, $insertPayload));
return true;
} }
} else { } else {
$query->delete(); return $query->delete() > 0;
} }
return false;
} }
private function syncArtworkStats(int $artworkId): void private function syncArtworkStats(int $artworkId): void
@@ -194,6 +231,10 @@ final class ArtworkInteractionController extends Controller
private function statusPayload(int $viewerId, int $artworkId): array 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') $isFavorited = Schema::hasTable('artwork_favourites')
? DB::table('artwork_favourites')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists() ? DB::table('artwork_favourites')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
: false; : false;
@@ -206,15 +247,21 @@ final class ArtworkInteractionController extends Controller
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count() ? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
: 0; : 0;
$bookmarks = Schema::hasTable('artwork_bookmarks')
? (int) DB::table('artwork_bookmarks')->where('artwork_id', $artworkId)->count()
: 0;
$likes = Schema::hasTable('artwork_likes') $likes = Schema::hasTable('artwork_likes')
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count() ? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
: 0; : 0;
return [ return [
'ok' => true, 'ok' => true,
'is_bookmarked' => $isBookmarked,
'is_favorited' => $isFavorited, 'is_favorited' => $isFavorited,
'is_liked' => $isLiked, 'is_liked' => $isLiked,
'stats' => [ 'stats' => [
'bookmarks' => $bookmarks,
'favorites' => $favorites, 'favorites' => $favorites,
'likes' => $likes, 'likes' => $likes,
], ],

View File

@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Artwork; use App\Models\Artwork;
use App\Services\ArtworkStatsService; use App\Services\ArtworkStatsService;
use App\Services\XPService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -26,7 +27,10 @@ use Illuminate\Http\Request;
*/ */
final class ArtworkViewController extends Controller 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 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. // Defer to Redis when available, fall back to direct DB increment.
$this->stats->incrementViews((int) $artwork->id, 1, defer: true); $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. // Mark this session so the artwork is not counted again.
if ($request->hasSession()) { if ($request->hasSession()) {
$request->session()->put($sessionKey, true); $request->session()->put($sessionKey, true);

View File

@@ -36,6 +36,10 @@ final class CommunityActivityController extends Controller
private function resolveFilter(Request $request): string 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')) { if ($request->boolean('following') && ! $request->filled('filter')) {
return 'following'; return 'following';
} }

View 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'))
);
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Services\Posts\NotificationDigestService; use App\Services\NotificationService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
@@ -14,48 +14,24 @@ use App\Http\Controllers\Controller;
*/ */
class NotificationController extends Controller class NotificationController extends Controller
{ {
public function __construct(private NotificationDigestService $digest) {} public function __construct(private NotificationService $notifications) {}
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$user = $request->user(); return response()->json(
$page = max(1, (int) $request->query('page', 1)); $this->notifications->listForUser($request->user(), (int) $request->query('page', 1), 20)
);
$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,
],
]);
} }
public function readAll(Request $request): JsonResponse 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.']); return response()->json(['message' => 'All notifications marked as read.']);
} }
public function markRead(Request $request, string $id): JsonResponse public function markRead(Request $request, string $id): JsonResponse
{ {
$notif = $request->user()->notifications()->findOrFail($id); $this->notifications->markRead($request->user(), $id);
$notif->markAsRead();
return response()->json(['message' => 'Notification marked as read.']); return response()->json(['message' => 'Notification marked as read.']);
} }
} }

View File

@@ -116,6 +116,8 @@ class PostCommentController extends Controller
'username' => $comment->user->username, 'username' => $comment->user->username,
'name' => $comment->user->name, 'name' => $comment->user->name,
'avatar' => $comment->user->profile?->avatar_url ?? null, 'avatar' => $comment->user->profile?->avatar_url ?? null,
'level' => (int) ($comment->user->level ?? 1),
'rank' => (string) ($comment->user->rank ?? 'Newbie'),
], ],
]; ];
} }

View File

@@ -7,9 +7,8 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\User; use App\Models\User;
use Carbon\CarbonInterface;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
use App\Services\ThumbnailService;
use App\Support\AvatarUrl;
use App\Support\UsernamePolicy; use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -57,20 +56,9 @@ final class ProfileApiController extends Controller
$perPage = 24; $perPage = 24;
$paginator = $query->cursorPaginate($perPage); $paginator = $query->cursorPaginate($perPage);
$data = collect($paginator->items())->map(function (Artwork $art) { $data = collect($paginator->items())
$present = ThumbnailPresenter::present($art, 'md'); ->map(fn (Artwork $art) => $this->mapArtworkCardPayload($art))
return [ ->values();
'id' => $art->id,
'name' => $art->title,
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'width' => $art->width,
'height' => $art->height,
'username' => $art->user->username ?? null,
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
'published_at' => $art->published_at,
];
})->values();
return response()->json([ return response()->json([
'data' => $data, 'data' => $data,
@@ -85,7 +73,8 @@ final class ProfileApiController extends Controller
*/ */
public function favourites(Request $request, string $username): JsonResponse 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]); return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
} }
@@ -95,16 +84,18 @@ final class ProfileApiController extends Controller
} }
$perPage = 24; $perPage = 24;
$cursor = $request->input('cursor'); $offset = max(0, (int) base64_decode((string) $request->input('cursor', ''), true));
$favIds = DB::table('user_favorites as uf') $favIds = DB::table($favouriteTable . ' as af')
->join('artworks as a', 'a.id', '=', 'uf.artwork_id') ->join('artworks as a', 'a.id', '=', 'af.artwork_id')
->where('uf.user_id', $user->id) ->where('af.user_id', $user->id)
->whereNull('a.deleted_at') ->whereNull('a.deleted_at')
->where('a.is_public', true) ->where('a.is_public', true)
->where('a.is_approved', true) ->where('a.is_approved', true)
->orderByDesc('uf.created_at') ->whereNotNull('a.published_at')
->offset($cursor ? (int) base64_decode($cursor) : 0) ->orderByDesc('af.created_at')
->orderByDesc('af.artwork_id')
->offset($offset)
->limit($perPage + 1) ->limit($perPage + 1)
->pluck('a.id'); ->pluck('a.id');
@@ -120,24 +111,14 @@ final class ProfileApiController extends Controller
->get() ->get()
->keyBy('id'); ->keyBy('id');
$data = $favIds->filter(fn ($id) => $indexed->has($id))->map(function ($id) use ($indexed) { $data = $favIds
$art = $indexed[$id]; ->filter(fn ($id) => $indexed->has($id))
$present = ThumbnailPresenter::present($art, 'md'); ->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id]))
return [ ->values();
'id' => $art->id,
'name' => $art->title,
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'width' => $art->width,
'height' => $art->height,
'username' => $art->user->username ?? null,
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
];
})->values();
return response()->json([ return response()->json([
'data' => $data, 'data' => $data,
'next_cursor' => null, // Simple offset pagination for now 'next_cursor' => $hasMore ? base64_encode((string) ($offset + $perPage)) : null,
'has_more' => $hasMore, 'has_more' => $hasMore,
]); ]);
} }
@@ -174,4 +155,48 @@ final class ProfileApiController extends Controller
$normalized = UsernamePolicy::normalize($username); $normalized = UsernamePolicy::normalize($username);
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first(); 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;
}
} }

View 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,
)
);
}
}

View 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(),
]);
}
}

View 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.']);
}
}

View 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))
);
}
}

View 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));
}
}

View 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));
}
}

View File

@@ -44,6 +44,10 @@ final class ArtworkDownloadController extends Controller
} }
$filePath = $this->resolveOriginalPath($hash, $ext); $filePath = $this->resolveOriginalPath($hash, $ext);
$this->recordDownload($request, $artwork->id);
$this->incrementDownloadCountIfAvailable($artwork->id);
if (! File::isFile($filePath)) { if (! File::isFile($filePath)) {
Log::warning('Artwork original file missing for download.', [ Log::warning('Artwork original file missing for download.', [
'artwork_id' => $artwork->id, 'artwork_id' => $artwork->id,
@@ -55,8 +59,6 @@ final class ArtworkDownloadController extends Controller
abort(404); abort(404);
} }
$this->recordDownload($request, $artwork->id);
$this->incrementDownloadCountIfAvailable($artwork->id);
$downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext); $downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext);

View File

@@ -21,6 +21,10 @@ class LatestController extends Controller
$perPage = 21; $perPage = 21;
$artworks = $this->artworks->browsePublicArtworks($perPage); $artworks = $this->artworks->browsePublicArtworks($perPage);
$artworks->getCollection()->load([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
]);
$artworks->getCollection()->transform(function (Artwork $artwork) { $artworks->getCollection()->transform(function (Artwork $artwork) {
$primaryCategory = $artwork->categories->sortBy('sort_order')->first(); $primaryCategory = $artwork->categories->sortBy('sort_order')->first();
@@ -34,10 +38,18 @@ class LatestController extends Controller
'content_type_name' => $primaryCategory?->contentType?->name ?? '', 'content_type_name' => $primaryCategory?->contentType?->name ?? '',
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '', 'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
'category_name' => $categoryName, 'category_name' => $categoryName,
'category_slug' => $primaryCategory?->slug ?? '',
'gid_num' => $gid, 'gid_num' => $gid,
'slug' => $artwork->slug,
'thumb_url' => $present['url'], 'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'], 'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user->name ?? 'Skinbase', '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 'published_at' => $artwork->published_at, // required by CursorPaginator
]; ];
}); });

View File

@@ -3,16 +3,67 @@
namespace App\Http\Controllers\Dashboard; namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\ReceivedCommentsInboxService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class CommentController extends Controller 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(); $user = $request->user();
// Minimal placeholder: real implementation should query comments received or made $search = trim((string) $request->query('q', ''));
$comments = []; $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,
],
]);
} }
} }

View File

@@ -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,
],
]);
}
}

View 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),
]);
}
}

View File

@@ -5,25 +5,60 @@ declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\DashboardPreference;
use App\Models\Story; use App\Models\Story;
use App\Models\User; use App\Models\User;
use App\Services\ReceivedCommentsInboxService;
use App\Services\XPService;
use App\Support\AvatarUrl; use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
final class DashboardController extends Controller final class DashboardController extends Controller
{ {
public function __construct(
private readonly XPService $xp,
private readonly ReceivedCommentsInboxService $receivedCommentsInbox,
) {}
public function index(Request $request) public function index(Request $request)
{ {
$user = $request->user(); $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', [ return view('dashboard', [
'page_title' => 'Dashboard', 'page_title' => 'Dashboard',
'dashboard_user_name' => $user?->username ?: $user?->name ?: 'Creator', '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, 'username' => $artwork->user?->username,
'name' => $artwork->user?->name, 'name' => $artwork->user?->name,
'url' => $artwork->user?->username ? '/@' . $artwork->user->username : null, '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.id',
'users.username', 'users.username',
'users.name', 'users.name',
'users.level',
'users.rank',
'up.avatar_hash', 'up.avatar_hash',
DB::raw('COALESCE(us.followers_count, 0) as followers_count'), DB::raw('COALESCE(us.followers_count, 0) as followers_count'),
DB::raw('COALESCE(us.uploads_count, 0) as uploads_count'), DB::raw('COALESCE(us.uploads_count, 0) as uploads_count'),
@@ -255,6 +294,8 @@ final class DashboardController extends Controller
'name' => $row->name, 'name' => $row->name,
'url' => $username !== '' ? '/@' . $username : null, 'url' => $username !== '' ? '/@' . $username : null,
'avatar' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64), '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, 'followers_count' => (int) $row->followers_count,
'uploads_count' => (int) $row->uploads_count, 'uploads_count' => (int) $row->uploads_count,
]; ];

View File

@@ -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,
]);
}
}

View File

@@ -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'));
}
}

View 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();
}
}

View File

@@ -3,48 +3,54 @@
namespace App\Http\Controllers\Legacy; namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Services\LegacyService;
use Illuminate\Support\Str;
class MembersController extends Controller 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) public function photos(Request $request, $id = null)
{ {
$id = (int) ($id ?: 545); $artworks = $this->artworks->getArtworksByContentType('photography', 40);
$result = $this->legacy->categoryPage('', null, $id); $artworks->getCollection()->load([
if (! $result) { 'user:id,name,username',
return redirect('/'); 'user.profile:user_id,avatar_hash',
} ]);
// categoryPage returns an array with keys used by legacy.browse $artworks->getCollection()->transform(function (Artwork $artwork) {
$page_title = $result['page_title'] ?? ($result['category']->category_name ?? 'Members Photos'); $primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$artworks = $result['artworks'] ?? collect(); $present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
// Ensure artworks include `slug`, `thumb`, and `thumb_srcset` properties expected by the legacy view return (object) [
if ($artworks && method_exists($artworks, 'getCollection')) { 'id' => $artwork->id,
$artworks->getCollection()->transform(function ($row) { 'name' => $artwork->title,
$row->slug = $row->slug ?? Str::slug($row->name ?? ''); 'slug' => $artwork->slug,
$row->thumb = $row->thumb ?? ($row->thumb_url ?? null); 'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
$row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null); 'thumb' => $present['url'],
return $row; 'thumb_url' => $present['url'],
}); 'thumb_srcset' => $present['srcset'] ?? $present['url'],
} elseif (is_iterable($artworks)) { 'uname' => $artwork->user->name ?? 'Skinbase',
$artworks = collect($artworks)->map(function ($row) { 'username' => $artwork->user->username ?? '',
$row->slug = $row->slug ?? Str::slug($row->name ?? ''); 'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($artwork->user->id ?? 0), $artwork->user->profile->avatar_hash ?? null, 64),
$row->thumb = $row->thumb ?? ($row->thumb_url ?? null); 'content_type_name' => $primaryCategory?->contentType?->name ?? 'Photography',
$row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null); 'content_type_slug' => $primaryCategory?->contentType?->slug ?? 'photography',
return $row; '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')); return view('legacy::browse', compact('page_title', 'artworks'));
} }

View File

@@ -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');
}
}

View File

@@ -18,7 +18,7 @@ class ReceivedCommentsController extends Controller
} }
$hits = 33; $hits = 33;
$page = max(1, (int) $request->query('page', 1)); $currentPage = max(1, (int) $request->query('page', 1));
$base = ArtworkComment::with(['user', 'artwork']) $base = ArtworkComment::with(['user', 'artwork'])
->whereHas('artwork', function ($q) use ($user) { ->whereHas('artwork', function ($q) use ($user) {
@@ -30,7 +30,7 @@ class ReceivedCommentsController extends Controller
return view('legacy::received-comments', [ return view('legacy::received-comments', [
'comments' => $comments, 'comments' => $comments,
'page' => $page, 'currentPage' => $currentPage,
'hits' => $hits, 'hits' => $hits,
'total' => $comments->total(), 'total' => $comments->total(),
]); ]);

View File

@@ -8,18 +8,20 @@ use DOMDocument;
use DOMElement; use DOMElement;
use DOMNode; use DOMNode;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\StoryComment;
use App\Models\Story; use App\Models\Story;
use App\Models\StoryTag; use App\Models\StoryTag;
use App\Models\StoryView; use App\Models\StoryView;
use App\Models\User; 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\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -88,7 +90,7 @@ class StoryController extends Controller
public function show(Request $request, string $slug): View public function show(Request $request, string $slug): View
{ {
$story = Story::published() $story = Story::published()
->with(['creator.profile', 'tags']) ->with(['creator.profile', 'creator.statistics', 'tags'])
->where('slug', $slug) ->where('slug', $slug)
->firstOrFail(); ->firstOrFail();
@@ -127,28 +129,49 @@ class StoryController extends Controller
->get(['id', 'title', 'slug']); ->get(['id', 'title', 'slug']);
} }
$discussionComments = collect(); $social = app(SocialService::class);
if ($story->creator_id !== null && Schema::hasTable('profile_comments')) { $initialComments = Schema::hasTable('story_comments')
$discussionComments = DB::table('profile_comments as pc') ? StoryComment::query()
->join('users as u', 'u.id', '=', 'pc.author_user_id') ->with(['user.profile', 'approvedReplies'])
->where('pc.profile_user_id', $story->creator_id) ->where('story_id', $story->id)
->where('pc.is_active', true) ->where('is_approved', true)
->orderByDesc('pc.created_at') ->whereNull('parent_id')
->limit(8) ->whereNull('deleted_at')
->get([ ->latest('created_at')
'pc.id', ->limit(10)
'pc.body', ->get()
'pc.created_at', ->map(fn (StoryComment $comment) => $social->formatComment($comment, $request->user()?->id, true))
'u.username as author_username', ->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', [ return view('web.stories.show', [
'story' => $story, 'story' => $story,
'safeContent' => $storyContentHtml, 'safeContent' => $storyContentHtml,
'relatedStories' => $relatedStories, 'relatedStories' => $relatedStories,
'relatedArtworks' => $relatedArtworks, 'relatedArtworks' => $relatedArtworks,
'comments' => $discussionComments, 'storySocialProps' => $storySocialProps,
'page_title' => $story->title . ' - Skinbase Stories', 'page_title' => $story->title . ' - Skinbase Stories',
'page_meta_description' => $story->excerpt ?: Str::limit(strip_tags((string) $story->content), 160), 'page_meta_description' => $story->excerpt ?: Str::limit(strip_tags((string) $story->content), 160),
'page_canonical' => route('stories.show', $story->slug), 'page_canonical' => route('stories.show', $story->slug),
@@ -212,6 +235,10 @@ class StoryController extends Controller
$story->tags()->sync($this->resolveTagIds($validated)); $story->tags()->sync($this->resolveTagIds($validated));
if ($resolved['status'] === 'published') {
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
}
if ($resolved['status'] === 'published') { if ($resolved['status'] === 'published') {
return redirect()->route('stories.show', ['slug' => $story->slug]) return redirect()->route('stories.show', ['slug' => $story->slug])
->with('status', 'Story published.'); ->with('status', 'Story published.');
@@ -275,6 +302,8 @@ class StoryController extends Controller
{ {
abort_unless($this->canManageStory($request, $story), 403); abort_unless($this->canManageStory($request, $story), 403);
$wasPublished = $story->published_at !== null || $story->status === 'published';
$validated = $this->validateStoryPayload($request); $validated = $this->validateStoryPayload($request);
$resolved = $this->resolveWorkflowState($request, $validated, false); $resolved = $this->resolveWorkflowState($request, $validated, false);
$serializedContent = $this->normalizeStoryContent($validated['content'] ?? []); $serializedContent = $this->normalizeStoryContent($validated['content'] ?? []);
@@ -302,6 +331,10 @@ class StoryController extends Controller
$story->tags()->sync($this->resolveTagIds($validated)); $story->tags()->sync($this->resolveTagIds($validated));
if (! $wasPublished && $resolved['status'] === 'published') {
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
}
return back()->with('status', 'Story updated.'); return back()->with('status', 'Story updated.');
} }
@@ -370,14 +403,10 @@ class StoryController extends Controller
{ {
abort_unless($this->canManageStory($request, $story), 403); abort_unless($this->canManageStory($request, $story), 403);
$story->update([ app(StoryPublicationService::class)->publish($story, 'published', [
'status' => 'published',
'published_at' => now(), '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.'); 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']])); $story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']]));
} }
if ($workflow['status'] === 'published') {
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
}
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'story_id' => (int) $story->id, 'story_id' => (int) $story->id,
'status' => $story->status, 'status' => $story->status,
'message' => 'Story created.', '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']); $story = Story::query()->findOrFail((int) $validated['story_id']);
abort_unless($this->canManageStory($request, $story), 403); abort_unless($this->canManageStory($request, $story), 403);
$wasPublished = $story->published_at !== null || $story->status === 'published';
$workflow = $this->resolveWorkflowState($request, array_merge([ $workflow = $this->resolveWorkflowState($request, array_merge([
'status' => $story->status, 'status' => $story->status,
@@ -576,11 +614,19 @@ class StoryController extends Controller
$story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']])); $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([ return response()->json([
'ok' => true, 'ok' => true,
'story_id' => (int) $story->id, 'story_id' => (int) $story->id,
'status' => $story->status, 'status' => $story->status,
'message' => 'Story updated.', '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), 'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null),
]); ]);
} else { } else {
$wasPublished = $story->published_at !== null || $story->status === 'published';
$nextContent = array_key_exists('content', $validated) $nextContent = array_key_exists('content', $validated)
? $this->normalizeStoryContent($validated['content']) ? $this->normalizeStoryContent($validated['content'])
: (string) $story->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, 'scheduled_for' => ! empty($validated['scheduled_for']) ? now()->parse((string) $validated['scheduled_for']) : $story->scheduled_for,
]); ]);
$story->save(); $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'])) { if (! empty($validated['tags_csv'])) {
@@ -666,6 +721,10 @@ class StoryController extends Controller
'story_id' => (int) $story->id, 'story_id' => (int) $story->id,
'saved_at' => now()->toIso8601String(), 'saved_at' => now()->toIso8601String(),
'message' => 'Saved just now', '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>', 'orderedList' => '<ol>' . $inner . '</ol>',
'listItem' => '<li>' . $inner . '</li>', 'listItem' => '<li>' . $inner . '</li>',
'horizontalRule' => '<hr>', 'horizontalRule' => '<hr>',
'codeBlock' => '<pre><code>' . e($this->extractTipTapText($node)) . '</code></pre>', 'codeBlock' => $this->renderCodeBlockNode($attrs, $node),
'image' => $this->renderImageNode($attrs), 'image' => $this->renderImageNode($attrs),
'artworkEmbed' => $this->renderArtworkEmbedNode($attrs), 'artworkEmbed' => $this->renderArtworkEmbedNode($attrs),
'galleryBlock' => $this->renderGalleryBlockNode($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 private function renderImageNode(array $attrs): string
{ {
$src = (string) ($attrs['src'] ?? ''); $src = (string) ($attrs['src'] ?? '');

View File

@@ -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'));
}
}

View File

@@ -3,68 +3,25 @@
namespace App\Http\Controllers\User; namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\UserStatsService; use App\Services\UserStatsService;
use App\Support\UsernamePolicy;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use App\Models\ArtworkFavourite;
class FavouritesController extends Controller class FavouritesController extends Controller
{ {
public function index(Request $request, $userId = null, $username = null) public function index(Request $request, $userId = null, $username = null)
{ {
$userId = $userId ? (int) $userId : ($request->user()->id ?? null); $user = $this->resolveLegacyFavouritesUser($request, $userId, $username);
if (! $user) {
$page = max(1, (int) $request->query('page', 1)); abort(404);
$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();
} }
$results = collect($results)->filter()->values()->transform(function ($row) { return redirect()->route('profile.show', [
$row->name = $row->name ?? $row->title ?? ''; 'username' => strtolower((string) $user->username),
$row->slug = $row->slug ?? Str::slug($row->name); 'tab' => 'favourites',
$row->encoded = isset($row->id) ? app(\App\Helpers\Thumb::class)::encodeId((int) $row->id) : null; ], 301);
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,
]);
} }
public function destroy(Request $request, $userId, $artworkId) public function destroy(Request $request, $userId, $artworkId)
@@ -82,6 +39,31 @@ class FavouritesController extends Controller
app(UserStatsService::class)->decrementFavoritesReceived($creatorId); 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();
} }
} }

View File

@@ -3,46 +3,54 @@
namespace App\Http\Controllers\User; namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Services\LegacyService;
use Illuminate\Support\Str;
class MembersController extends Controller 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) public function photos(Request $request, $id = null)
{ {
$id = (int) ($id ?: 545); $artworks = $this->artworks->getArtworksByContentType('photography', 40);
$result = $this->legacy->categoryPage('', null, $id); $artworks->getCollection()->load([
if (! $result) { 'user:id,name,username',
return redirect('/'); 'user.profile:user_id,avatar_hash',
} ]);
$page_title = $result['page_title'] ?? ($result['category']->category_name ?? 'Members Photos'); $artworks->getCollection()->transform(function (Artwork $artwork) {
$artworks = $result['artworks'] ?? collect(); $primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
if ($artworks && method_exists($artworks, 'getCollection')) { return (object) [
$artworks->getCollection()->transform(function ($row) { 'id' => $artwork->id,
$row->slug = $row->slug ?? Str::slug($row->name ?? ''); 'name' => $artwork->title,
$row->thumb = $row->thumb ?? ($row->thumb_url ?? null); 'slug' => $artwork->slug,
$row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null); 'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
return $row; 'thumb' => $present['url'],
}); 'thumb_url' => $present['url'],
} elseif (is_iterable($artworks)) { 'thumb_srcset' => $present['srcset'] ?? $present['url'],
$artworks = collect($artworks)->map(function ($row) { 'uname' => $artwork->user->name ?? 'Skinbase',
$row->slug = $row->slug ?? Str::slug($row->name ?? ''); 'username' => $artwork->user->username ?? '',
$row->thumb = $row->thumb ?? ($row->thumb_url ?? null); 'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($artwork->user->id ?? 0), $artwork->user->profile->avatar_hash ?? null, 64),
$row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null); 'content_type_name' => $primaryCategory?->contentType?->name ?? 'Photography',
return $row; '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')); return view('web.members.photos', compact('page_title', 'artworks'));
} }

View File

@@ -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');
}
}

View File

@@ -14,15 +14,21 @@ use App\Http\Requests\Settings\VerifyEmailChangeRequest;
use App\Mail\EmailChangedSecurityAlertMail; use App\Mail\EmailChangedSecurityAlertMail;
use App\Mail\EmailChangeVerificationCodeMail; use App\Mail\EmailChangeVerificationCodeMail;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\Country;
use App\Models\ProfileComment; use App\Models\ProfileComment;
use App\Models\Story; use App\Models\Story;
use App\Models\User; use App\Models\User;
use Carbon\CarbonInterface;
use App\Services\Security\CaptchaVerifier; use App\Services\Security\CaptchaVerifier;
use App\Services\AvatarService; use App\Services\AvatarService;
use App\Services\ArtworkService; use App\Services\ArtworkService;
use App\Services\FollowService; use App\Services\FollowService;
use App\Services\AchievementService;
use App\Services\LeaderboardService;
use App\Services\Countries\CountryCatalogService;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
use App\Services\ThumbnailService; use App\Services\ThumbnailService;
use App\Services\XPService;
use App\Services\UsernameApprovalService; use App\Services\UsernameApprovalService;
use App\Services\UserStatsService; use App\Services\UserStatsService;
use App\Support\AvatarUrl; use App\Support\AvatarUrl;
@@ -49,6 +55,10 @@ class ProfileController extends Controller
private readonly FollowService $followService, private readonly FollowService $followService,
private readonly UserStatsService $userStats, private readonly UserStatsService $userStats,
private readonly CaptchaVerifier $captchaVerifier, 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 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) 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'], 'body' => ['required', 'string', 'min:2', 'max:2000'],
]); ]);
ProfileComment::create([ $comment = ProfileComment::create([
'profile_user_id' => $target->id, 'profile_user_id' => $target->id,
'author_user_id' => Auth::id(), 'author_user_id' => Auth::id(),
'body' => $request->input('body'), '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)]) return Redirect::route('profile.show', ['username' => strtolower((string) $target->username)])
->with('status', 'Comment posted!'); ->with('status', 'Comment posted!');
} }
public function edit(Request $request): View public function edit(Request $request): View
{ {
$user = $request->user()->loadMissing(['profile', 'country']);
$selectedCountry = $this->countryCatalog->resolveUserCountry($user);
return view('profile.edit', [ 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) public function editSettings(Request $request)
{ {
$user = $request->user(); $user = $request->user()->loadMissing(['profile', 'country']);
$cooldownDays = $this->usernameCooldownDays(); $cooldownDays = $this->usernameCooldownDays();
$lastUsernameChangeAt = $this->lastUsernameChangeAt($user); $lastUsernameChangeAt = $this->lastUsernameChangeAt($user);
$usernameCooldownRemainingDays = 0; $usernameCooldownRemainingDays = 0;
@@ -188,15 +229,8 @@ class ProfileController extends Controller
} catch (\Throwable $e) {} } catch (\Throwable $e) {}
} }
// Country list $selectedCountry = $this->countryCatalog->resolveUserCountry($user);
$countries = collect(); $countries = $this->countryCatalog->profileSelectOptions();
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) {}
// Avatar URL // Avatar URL
$avatarHash = $profileData['avatar_hash'] ?? $user->icon ?? null; $avatarHash = $profileData['avatar_hash'] ?? $user->icon ?? null;
@@ -222,7 +256,8 @@ class ProfileController extends Controller
'description' => $user->description ?? null, 'description' => $user->description ?? null,
'gender' => $user->gender ?? null, 'gender' => $user->gender ?? null,
'birthday' => $user->birth ?? 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, 'email_notifications' => $emailNotifications,
'upload_notifications' => $uploadNotifications, 'upload_notifications' => $uploadNotifications,
'follower_notifications' => $followerNotifications, 'follower_notifications' => $followerNotifications,
@@ -238,7 +273,7 @@ class ProfileController extends Controller
'usernameCooldownDays' => $cooldownDays, 'usernameCooldownDays' => $cooldownDays,
'usernameCooldownRemainingDays' => $usernameCooldownRemainingDays, 'usernameCooldownRemainingDays' => $usernameCooldownRemainingDays,
'usernameCooldownActive' => $usernameCooldownRemainingDays > 0, 'usernameCooldownActive' => $usernameCooldownRemainingDays > 0,
'countries' => $countries->values(), 'countries' => $countries,
'flash' => [ 'flash' => [
'status' => session('status'), 'status' => session('status'),
'error' => session('error'), 'error' => session('error'),
@@ -434,10 +469,12 @@ class ProfileController extends Controller
public function updatePersonalSection(UpdatePersonalSectionRequest $request): RedirectResponse|JsonResponse public function updatePersonalSection(UpdatePersonalSectionRequest $request): RedirectResponse|JsonResponse
{ {
$validated = $request->validated(); $validated = $request->validated();
$selectedCountry = $this->resolveCountrySelection($validated['country_id'] ?? null);
$this->persistUserCountrySelection($request->user(), $selectedCountry);
$profileUpdates = [ $profileUpdates = [
'birthdate' => $validated['birthday'] ?? null, 'birthdate' => $validated['birthday'] ?? null,
'country_code' => $validated['country'] ?? null, 'country_code' => $selectedCountry?->iso2,
]; ];
if (!empty($validated['gender'])) { if (!empty($validated['gender'])) {
@@ -513,6 +550,29 @@ class ProfileController extends Controller
DB::table('user_profiles')->updateOrInsert(['user_id' => $userId], $filtered); 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 private function usernameCooldownDays(): int
{ {
return max(1, (int) config('usernames.rename_cooldown_days', 30)); 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']); $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)) { if (array_key_exists('mailing', $validated)) {
$profileUpdates['mlist'] = filter_var($validated['mailing'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0; $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'); 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; $isOwner = Auth::check() && Auth::id() === $user->id;
$viewer = Auth::user(); $viewer = Auth::user();
@@ -777,21 +845,7 @@ class ProfileController extends Controller
// ── Artworks (cursor-paginated) ────────────────────────────────────── // ── Artworks (cursor-paginated) ──────────────────────────────────────
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage) $artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage)
->through(function (Artwork $art) { ->through(function (Artwork $art) {
$present = ThumbnailPresenter::present($art, 'md'); return (object) $this->mapArtworkCardPayload($art);
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,
];
}); });
// ── Featured artworks for this user ───────────────────────────────── // ── Featured artworks for this user ─────────────────────────────────
@@ -829,27 +883,42 @@ class ProfileController extends Controller
} }
// ── Favourites ─────────────────────────────────────────────────────── // ── Favourites ───────────────────────────────────────────────────────
$favourites = collect(); $favouriteLimit = 12;
if (Schema::hasTable('user_favorites')) { $favouriteTable = $this->resolveFavouriteTable();
$favIds = DB::table('user_favorites as uf') $favourites = [
->join('artworks as a', 'a.id', '=', 'uf.artwork_id') 'data' => [],
->where('uf.user_id', $user->id) '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') ->whereNull('a.deleted_at')
->where('a.is_public', true) ->where('a.is_public', true)
->where('a.is_approved', true) ->where('a.is_approved', true)
->orderByDesc('uf.created_at') ->whereNotNull('a.published_at')
->limit(12) ->orderByDesc('af.created_at')
->orderByDesc('af.artwork_id')
->limit($favouriteLimit + 1)
->pluck('a.id'); ->pluck('a.id');
if ($favIds->isNotEmpty()) { if ($favIds->isNotEmpty()) {
$hasMore = $favIds->count() > $favouriteLimit;
$favIds = $favIds->take($favouriteLimit);
$indexed = Artwork::with('user:id,name,username') $indexed = Artwork::with('user:id,name,username')
->whereIn('id', $favIds) ->whereIn('id', $favIds)
->get() ->get()
->keyBy('id'); ->keyBy('id');
// Preserve the ordering from the favourites table
$favourites = $favIds $favourites = [
'data' => $favIds
->filter(fn ($id) => $indexed->has($id)) ->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([ ->select([
'pc.id', 'pc.body', 'pc.created_at', 'pc.id', 'pc.body', 'pc.created_at',
'u.id as author_id', 'u.username as author_username', 'u.name as author_name', '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', 'up.avatar_hash as author_avatar_hash', 'up.signature as author_signature',
]) ])
->get() ->get()
@@ -925,12 +995,16 @@ class ProfileController extends Controller
'created_at' => $row->created_at, 'created_at' => $row->created_at,
'author_id' => $row->author_id, 'author_id' => $row->author_id,
'author_name' => $row->author_username ?? $row->author_name ?? 'Unknown', '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_profile_url' => '/@' . strtolower((string) ($row->author_username ?? $row->author_id)),
'author_avatar' => AvatarUrl::forUser((int) $row->author_id, $row->author_avatar_hash, 50), 'author_avatar' => AvatarUrl::forUser((int) $row->author_id, $row->author_avatar_hash, 50),
'author_signature' => $row->author_signature, 'author_signature' => $row->author_signature,
]); ]);
} }
$xpSummary = $this->xp->summary((int) $user->id);
$creatorStories = Story::query() $creatorStories = Story::query()
->published() ->published()
->with(['tags']) ->with(['tags'])
@@ -959,21 +1033,19 @@ class ProfileController extends Controller
'views' => (int) $story->views, 'views' => (int) $story->views,
'likes_count' => (int) $story->likes_count, 'likes_count' => (int) $story->likes_count,
'comments_count' => (int) $story->comments_count, 'comments_count' => (int) $story->comments_count,
'creator_level' => $xpSummary['level'],
'creator_rank' => $xpSummary['rank'],
'published_at' => $story->published_at?->toISOString(), 'published_at' => $story->published_at?->toISOString(),
]); ]);
// ── Profile data ───────────────────────────────────────────────────── // ── Profile data ─────────────────────────────────────────────────────
$profile = $user->profile; $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) ────────── if ($countryName === null && $profile?->country_code) {
$countryName = null; $countryName = strtoupper((string) $profile->country_code);
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);
} }
// ── Cover image hero (preferred) ──────────────────────────────────── // ── 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' => [ 'user' => [
'id' => $user->id, 'id' => $user->id,
'username' => $user->username, 'username' => $user->username,
@@ -1025,18 +1101,25 @@ class ProfileController extends Controller
'cover_position'=> (int) ($user->cover_position ?? 50), 'cover_position'=> (int) ($user->cover_position ?? 50),
'created_at' => $user->created_at?->toISOString(), 'created_at' => $user->created_at?->toISOString(),
'last_visit_at' => $user->last_visit_at ? (string) $user->last_visit_at : null, '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 ? [ 'profile' => $profile ? [
'about' => $profile->about ?? null, 'about' => $profile->about ?? null,
'website' => $profile->website ?? null, 'website' => $profile->website ?? null,
'country_code' => $profile->country_code ?? null, 'country_code' => $countryCode,
'gender' => $profile->gender ?? null, 'gender' => $profile->gender ?? null,
'birthdate' => $profile->birthdate ?? null, 'birthdate' => $profile->birthdate ?? null,
'cover_image' => $profile->cover_image ?? null, 'cover_image' => $profile->cover_image ?? null,
] : null, ] : null,
'artworks' => $artworkPayload, 'artworks' => $artworkPayload,
'featuredArtworks' => $featuredArtworks->values(), 'featuredArtworks' => $featuredArtworks->values(),
'favourites' => $favourites->values(), 'favourites' => $favourites,
'stats' => $stats, 'stats' => $stats,
'socialLinks' => $socialLinks, 'socialLinks' => $socialLinks,
'followerCount' => $followerCount, 'followerCount' => $followerCount,
@@ -1045,14 +1128,71 @@ class ProfileController extends Controller
'heroBgUrl' => $heroBgUrl, 'heroBgUrl' => $heroBgUrl,
'profileComments' => $profileComments->values(), 'profileComments' => $profileComments->values(),
'creatorStories' => $creatorStories->values(), 'creatorStories' => $creatorStories->values(),
'achievements' => $achievementSummary,
'leaderboardRank' => $leaderboardRank,
'countryName' => $countryName, 'countryName' => $countryName,
'isOwner' => $isOwner, 'isOwner' => $isOwner,
'auth' => $authData, 'auth' => $authData,
'profileUrl' => $canonical,
'galleryUrl' => $galleryUrl,
])->withViewData([ ])->withViewData([
'page_title' => ($user->username ?? $user->name ?? 'User') . ' on Skinbase', 'page_title' => $galleryOnly
'page_canonical' => $canonical, ? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
'page_meta_description' => 'View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.', : (($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, '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;
}
} }

View File

@@ -5,7 +5,6 @@ namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\ArtworkDownload; use App\Models\ArtworkDownload;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Carbon\Carbon; use Carbon\Carbon;
@@ -17,7 +16,11 @@ class TodayDownloadsController extends Controller
$today = Carbon::now()->toDateString(); $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) ->whereDate('created_at', $today)
->whereHas('artwork', function ($q) { ->whereHas('artwork', function ($q) {
$q->public()->published()->whereNull('deleted_at'); $q->public()->published()->whereNull('deleted_at');
@@ -34,13 +37,31 @@ class TodayDownloadsController extends Controller
$art = \App\Models\Artwork::find($row->artwork_id); $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; $name = $art->title ?? null;
$picture = $art->file_name ?? null; $picture = $art->file_name ?? null;
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg'; $ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
$encoded = null; $encoded = null;
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null; $present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
$thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp'; $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) [ return (object) [
'id' => $art->id ?? null, 'id' => $art->id ?? null,
@@ -50,8 +71,17 @@ class TodayDownloadsController extends Controller
'ext' => $ext, 'ext' => $ext,
'encoded' => $encoded, 'encoded' => $encoded,
'thumb' => $thumb, 'thumb' => $thumb,
'thumb_url' => $thumb,
'thumb_srcset' => $thumb, 'thumb_srcset' => $thumb,
'category' => $categoryId, '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, 'num_downloads' => $row->num_downloads ?? 0,
'gid_num' => $categoryId ? ((int) $categoryId % 5) * 5 : 0, 'gid_num' => $categoryId ? ((int) $categoryId % 5) * 5 : 0,
]; ];

View File

@@ -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 private function presentArtwork(Artwork $artwork): object
{ {
$primaryCategory = $artwork->categories->sortBy('sort_order')->first(); $primaryCategory = $artwork->categories->sortBy('sort_order')->first();

View File

@@ -45,6 +45,10 @@ final class CommunityActivityController extends Controller
private function resolveFilter(Request $request): string 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')) { if ($request->boolean('following') && ! $request->filled('filter')) {
return 'following'; return 'following';
} }

View File

@@ -4,51 +4,23 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\Artwork;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Schema; use App\Support\UsernamePolicy;
class GalleryController extends Controller class GalleryController extends Controller
{ {
public function show(Request $request, $userId, $username = null) public function show(Request $request, $userId, $username = null)
{ {
$user = User::find((int)$userId); $user = User::find((int) $userId);
if (! $user) { if (! $user) {
abort(404); abort(404);
} }
// canonicalize username in URL when possible $usernameSlug = UsernamePolicy::normalize((string) ($user->username ?? $user->name ?? ''));
try { if ($usernameSlug === '') {
$correctName = $user->name ?? $user->uname ?? null; abort(404);
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
} }
$page = max(1, (int) $request->query('page', 1)); return redirect()->route('profile.gallery', ['username' => $usernameSlug], 301);
$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,
]);
} }
} }

View 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.',
],
]);
}
}

View File

@@ -16,6 +16,10 @@ final class HandleInertiaRequests extends Middleware
*/ */
public function rootView(Request $request): string public function rootView(Request $request): string
{ {
if ($request->path() === 'leaderboard') {
return 'leaderboard';
}
if (str_starts_with($request->path(), 'studio')) { if (str_starts_with($request->path(), 'studio')) {
return 'studio'; return 'studio';
} }

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Models\User;
use App\Support\UsernamePolicy;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
class RedirectLegacyProfileSubdomain
{
public function handle(Request $request, Closure $next): Response
{
$canonicalUsername = $this->resolveCanonicalUsername($request);
if ($canonicalUsername !== null) {
return redirect()->to($this->targetUrl($request, $canonicalUsername), 301);
}
return $next($request);
}
private function resolveCanonicalUsername(Request $request): ?string
{
$configuredHost = parse_url((string) config('app.url'), PHP_URL_HOST);
if (! is_string($configuredHost) || $configuredHost === '') {
return null;
}
$requestHost = strtolower($request->getHost());
$configuredHost = strtolower($configuredHost);
if ($requestHost === $configuredHost || ! str_ends_with($requestHost, '.' . $configuredHost)) {
return null;
}
$subdomain = substr($requestHost, 0, -strlen('.' . $configuredHost));
if ($subdomain === '' || str_contains($subdomain, '.')) {
return null;
}
$candidate = UsernamePolicy::normalize($subdomain);
if ($candidate === '' || $this->isReservedSubdomain($candidate)) {
return null;
}
$username = User::query()
->whereRaw('LOWER(username) = ?', [$candidate])
->value('username');
if (is_string($username) && $username !== '') {
return UsernamePolicy::normalize($username);
}
if (! Schema::hasTable('username_redirects')) {
return null;
}
$redirect = DB::table('username_redirects')
->whereRaw('LOWER(old_username) = ?', [$candidate])
->value('new_username');
return is_string($redirect) && $redirect !== ''
? UsernamePolicy::normalize($redirect)
: null;
}
private function isReservedSubdomain(string $candidate): bool
{
$reserved = UsernamePolicy::reserved();
foreach ([config('cp.webroot'), config('cpad.webroot')] as $prefix) {
$value = strtolower(trim((string) $prefix, '/'));
if ($value !== '') {
$reserved[] = $value;
}
}
return in_array($candidate, array_values(array_unique($reserved)), true);
}
private function targetUrl(Request $request, string $username): string
{
$canonicalPath = match ($request->getPathInfo()) {
'/gallery', '/gallery/' => '/@' . $username . '/gallery',
default => '/@' . $username,
};
$target = rtrim((string) config('app.url'), '/') . $canonicalPath;
$query = $request->getQueryString();
if (is_string($query) && $query !== '') {
$target .= '?' . $query;
}
return $target;
}
}

View File

@@ -32,7 +32,8 @@ class ProfileUpdateRequest extends FormRequest
'month' => ['nullable', 'numeric', 'between:1,12'], 'month' => ['nullable', 'numeric', 'between:1,12'],
'year' => ['nullable', 'numeric', 'digits:4'], 'year' => ['nullable', 'numeric', 'digits:4'],
'gender' => ['nullable', 'in:m,f,n,M,F,N,X,x'], 'gender' => ['nullable', 'in:m,f,n,M,F,N,X,x'],
'country' => ['nullable', 'string', 'max:10'], 'country' => ['nullable', 'string', 'size:2'],
'country_id' => ['nullable', 'integer', Rule::exists('countries', 'id')],
'mailing' => ['nullable', 'boolean'], 'mailing' => ['nullable', 'boolean'],
'notify' => ['nullable', 'boolean'], 'notify' => ['nullable', 'boolean'],
'auto_post_upload' => ['nullable', 'boolean'], 'auto_post_upload' => ['nullable', 'boolean'],

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\Settings; namespace App\Http\Requests\Settings;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdatePersonalSectionRequest extends FormRequest class UpdatePersonalSectionRequest extends FormRequest
{ {
@@ -18,7 +19,7 @@ class UpdatePersonalSectionRequest extends FormRequest
return [ return [
'birthday' => ['nullable', 'date', 'before:today'], 'birthday' => ['nullable', 'date', 'before:today'],
'gender' => ['nullable', 'in:m,f,x,M,F,X'], 'gender' => ['nullable', 'in:m,f,x,M,F,X'],
'country' => ['nullable', 'string', 'max:10'], 'country_id' => ['nullable', 'integer', Rule::exists('countries', 'id')],
]; ];
} }
} }

View File

@@ -42,9 +42,14 @@ class ArtworkResource extends JsonResource
$viewerId = (int) optional($request->user())->id; $viewerId = (int) optional($request->user())->id;
$isLiked = false; $isLiked = false;
$isFavorited = false; $isFavorited = false;
$isBookmarked = false;
$isFollowing = false; $isFollowing = false;
$viewerAward = null; $viewerAward = null;
$bookmarksCount = Schema::hasTable('artwork_bookmarks')
? (int) DB::table('artwork_bookmarks')->where('artwork_id', (int) $this->id)->count()
: 0;
if ($viewerId > 0) { if ($viewerId > 0) {
if (Schema::hasTable('artwork_likes')) { if (Schema::hasTable('artwork_likes')) {
$isLiked = DB::table('artwork_likes') $isLiked = DB::table('artwork_likes')
@@ -53,6 +58,13 @@ class ArtworkResource extends JsonResource
->exists(); ->exists();
} }
if (Schema::hasTable('artwork_bookmarks')) {
$isBookmarked = DB::table('artwork_bookmarks')
->where('user_id', $viewerId)
->where('artwork_id', (int) $this->id)
->exists();
}
$isFavorited = DB::table('artwork_favourites') $isFavorited = DB::table('artwork_favourites')
->where('user_id', $viewerId) ->where('user_id', $viewerId)
->where('artwork_id', (int) $this->id) ->where('artwork_id', (int) $this->id)
@@ -114,6 +126,7 @@ class ArtworkResource extends JsonResource
'followers_count' => $followerCount, 'followers_count' => $followerCount,
], ],
'viewer' => [ 'viewer' => [
'is_bookmarked' => $isBookmarked,
'is_liked' => $isLiked, 'is_liked' => $isLiked,
'is_favorited' => $isFavorited, 'is_favorited' => $isFavorited,
'is_following_author' => $isFollowing, 'is_following_author' => $isFollowing,
@@ -121,6 +134,7 @@ class ArtworkResource extends JsonResource
'id' => $viewerId > 0 ? $viewerId : null, 'id' => $viewerId > 0 ? $viewerId : null,
], ],
'stats' => [ 'stats' => [
'bookmarks' => $bookmarksCount,
'views' => (int) ($this->stats?->views ?? 0), 'views' => (int) ($this->stats?->views ?? 0),
'downloads' => (int) ($this->stats?->downloads ?? 0), 'downloads' => (int) ($this->stats?->downloads ?? 0),
'favorites' => (int) ($this->stats?->favorites ?? 0), 'favorites' => (int) ($this->stats?->favorites ?? 0),

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Services\LeaderboardService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class UpdateLeaderboardsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 1200;
public function handle(LeaderboardService $leaderboards): void
{
$leaderboards->refreshAll();
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Listeners\Achievements;
use App\Events\Achievements\AchievementCheckRequested;
use App\Events\Achievements\UserXpUpdated;
use App\Services\AchievementService;
class CheckUserAchievements
{
public function __construct(private readonly AchievementService $achievements) {}
public function handle(AchievementCheckRequested|UserXpUpdated $event): void
{
$this->achievements->checkAchievements($event->userId);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Listeners\Posts;
use App\Events\Posts\PostCommented;
use App\Services\XPService;
class AwardXpForPostCommented
{
public function __construct(private readonly XPService $xp) {}
public function handle(PostCommented $event): void
{
$this->xp->awardCommentCreated((int) $event->commenter->id, (int) $event->comment->id, 'post');
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Models\UserAchievement;
class Achievement extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'description',
'icon',
'xp_reward',
'type',
'condition_type',
'condition_value',
];
protected function casts(): array
{
return [
'xp_reward' => 'integer',
'condition_value' => 'integer',
];
}
public function userAchievements(): HasMany
{
return $this->hasMany(UserAchievement::class, 'achievement_id');
}
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'user_achievements', 'achievement_id', 'user_id')
->withPivot('unlocked_at');
}
}

View File

@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* Unified activity feed event. * Unified activity feed event.
* *
* Types: upload | comment | favorite | award | follow * Types: upload | comment | favorite | award | follow
* target_type: artwork | user * target_type: artwork | story | user
* *
* @property int $id * @property int $id
* @property int $actor_id * @property int $actor_id
@@ -54,6 +54,7 @@ class ActivityEvent extends Model
const TYPE_FOLLOW = 'follow'; const TYPE_FOLLOW = 'follow';
const TARGET_ARTWORK = 'artwork'; const TARGET_ARTWORK = 'artwork';
const TARGET_STORY = 'story';
const TARGET_USER = 'user'; const TARGET_USER = 'user';
// ── Relations ───────────────────────────────────────────────────────────── // ── Relations ─────────────────────────────────────────────────────────────

88
app/Models/Country.php Normal file
View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Country extends Model
{
use HasFactory;
protected $fillable = [
'iso',
'iso2',
'iso3',
'numeric_code',
'name',
'native',
'phone',
'continent',
'capital',
'currency',
'languages',
'name_common',
'name_official',
'region',
'subregion',
'flag_svg_url',
'flag_png_url',
'flag_emoji',
'active',
'sort_order',
'is_featured',
];
protected function casts(): array
{
return [
'active' => 'boolean',
'is_featured' => 'boolean',
'sort_order' => 'integer',
];
}
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
public function scopeOrdered(Builder $query): Builder
{
return $query
->orderByDesc('is_featured')
->orderBy('sort_order')
->orderBy('name_common');
}
public function getFlagCssClassAttribute(): ?string
{
$iso2 = strtoupper((string) $this->iso2);
if (! preg_match('/^[A-Z]{2}$/', $iso2)) {
return null;
}
return 'fi fi-'.strtolower($iso2);
}
public function getLocalFlagPathAttribute(): ?string
{
$iso2 = strtoupper((string) $this->iso2);
if (! preg_match('/^[A-Z]{2}$/', $iso2)) {
return null;
}
return '/gfx/flags/shiny/24/'.rawurlencode($iso2).'.png';
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DashboardPreference extends Model
{
public const MAX_PINNED_SPACES = 8;
/**
* @var list<string>
*/
private const ALLOWED_PINNED_SPACES = [
'/dashboard/profile',
'/dashboard/notifications',
'/dashboard/comments/received',
'/dashboard/followers',
'/dashboard/following',
'/dashboard/favorites',
'/dashboard/artworks',
'/dashboard/gallery',
'/dashboard/awards',
'/creator/stories',
'/studio',
];
protected $table = 'dashboard_preferences';
protected $primaryKey = 'user_id';
public $incrementing = false;
protected $keyType = 'int';
protected $fillable = [
'user_id',
'pinned_spaces',
];
protected function casts(): array
{
return [
'pinned_spaces' => 'array',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* @param array<int, mixed> $hrefs
* @return list<string>
*/
public static function sanitizePinnedSpaces(array $hrefs): array
{
$allowed = array_fill_keys(self::ALLOWED_PINNED_SPACES, true);
$sanitized = [];
foreach ($hrefs as $href) {
if (! is_string($href) || ! isset($allowed[$href])) {
continue;
}
if (in_array($href, $sanitized, true)) {
continue;
}
$sanitized[] = $href;
if (count($sanitized) >= self::MAX_PINNED_SPACES) {
break;
}
}
return $sanitized;
}
/**
* @return list<string>
*/
public static function pinnedSpacesForUser(User $user): array
{
$preference = static::query()->find($user->id);
$spaces = $preference?->pinned_spaces;
return is_array($spaces) ? static::sanitizePinnedSpaces($spaces) : [];
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Leaderboard extends Model
{
use HasFactory;
public const TYPE_CREATOR = 'creator';
public const TYPE_ARTWORK = 'artwork';
public const TYPE_STORY = 'story';
public const PERIOD_DAILY = 'daily';
public const PERIOD_WEEKLY = 'weekly';
public const PERIOD_MONTHLY = 'monthly';
public const PERIOD_ALL_TIME = 'all_time';
protected $fillable = [
'type',
'entity_id',
'score',
'period',
];
protected function casts(): array
{
return [
'entity_id' => 'integer',
'score' => 'float',
];
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\StoryLike; use App\Models\StoryLike;
use App\Models\StoryBookmark;
use App\Models\StoryComment;
use App\Models\StoryView; use App\Models\StoryView;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -101,6 +103,16 @@ class Story extends Model
return $this->hasMany(StoryLike::class, 'story_id'); return $this->hasMany(StoryLike::class, 'story_id');
} }
public function comments(): HasMany
{
return $this->hasMany(StoryComment::class, 'story_id');
}
public function bookmarks(): HasMany
{
return $this->hasMany(StoryBookmark::class, 'story_id');
}
// ── Scopes ─────────────────────────────────────────────────────────── // ── Scopes ───────────────────────────────────────────────────────────
public function scopePublished($query) public function scopePublished($query)

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StoryBookmark extends Model
{
use HasFactory;
protected $fillable = [
'story_id',
'user_id',
];
protected $casts = [
'story_id' => 'integer',
'user_id' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function story(): BelongsTo
{
return $this->belongsTo(Story::class, 'story_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class StoryComment extends Model
{
use HasFactory;
use SoftDeletes;
protected $fillable = [
'story_id',
'user_id',
'parent_id',
'content',
'raw_content',
'rendered_content',
'is_approved',
];
protected $casts = [
'story_id' => 'integer',
'user_id' => 'integer',
'parent_id' => 'integer',
'is_approved' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
];
public function story(): BelongsTo
{
return $this->belongsTo(Story::class, 'story_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function replies(): HasMany
{
return $this->hasMany(self::class, 'parent_id')->orderBy('created_at');
}
public function approvedReplies(): HasMany
{
return $this->replies()->where('is_approved', true)->whereNull('deleted_at')->with(['user.profile', 'approvedReplies']);
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; // use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Models\SocialAccount; use App\Models\SocialAccount;
@@ -12,6 +13,9 @@ use App\Models\Conversation;
use App\Models\ConversationParticipant; use App\Models\ConversationParticipant;
use App\Models\Message; use App\Models\Message;
use App\Models\Notification; use App\Models\Notification;
use App\Models\Achievement;
use App\Models\UserAchievement;
use App\Models\UserXpLog;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
@@ -50,6 +54,9 @@ class User extends Authenticatable
'spam_reports', 'spam_reports',
'approved_posts', 'approved_posts',
'flagged_posts', 'flagged_posts',
'xp',
'level',
'rank',
'password', 'password',
'role', 'role',
'allow_messages_from', 'allow_messages_from',
@@ -88,6 +95,9 @@ class User extends Authenticatable
'spam_reports' => 'integer', 'spam_reports' => 'integer',
'approved_posts' => 'integer', 'approved_posts' => 'integer',
'flagged_posts' => 'integer', 'flagged_posts' => 'integer',
'xp' => 'integer',
'level' => 'integer',
'rank' => 'string',
'password' => 'hashed', 'password' => 'hashed',
'allow_messages_from' => 'string', 'allow_messages_from' => 'string',
]; ];
@@ -108,6 +118,16 @@ class User extends Authenticatable
return $this->hasOne(UserProfile::class, 'user_id'); return $this->hasOne(UserProfile::class, 'user_id');
} }
public function dashboardPreference(): HasOne
{
return $this->hasOne(DashboardPreference::class, 'user_id');
}
public function country(): BelongsTo
{
return $this->belongsTo(Country::class);
}
public function statistics(): HasOne public function statistics(): HasOne
{ {
return $this->hasOne(UserStatistic::class, 'user_id'); return $this->hasOne(UserStatistic::class, 'user_id');
@@ -140,6 +160,22 @@ class User extends Authenticatable
return $this->hasMany(ProfileComment::class, 'profile_user_id'); return $this->hasMany(ProfileComment::class, 'profile_user_id');
} }
public function xpLogs(): HasMany
{
return $this->hasMany(UserXpLog::class, 'user_id');
}
public function userAchievements(): HasMany
{
return $this->hasMany(UserAchievement::class, 'user_id');
}
public function achievements(): BelongsToMany
{
return $this->belongsToMany(Achievement::class, 'user_achievements', 'user_id', 'achievement_id')
->withPivot('unlocked_at');
}
// ── Messaging ──────────────────────────────────────────────────────────── // ── Messaging ────────────────────────────────────────────────────────────
public function conversations(): BelongsToMany public function conversations(): BelongsToMany

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use App\Models\Achievement;
class UserAchievement extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'user_id',
'achievement_id',
'unlocked_at',
];
protected function casts(): array
{
return [
'user_id' => 'integer',
'achievement_id' => 'integer',
'unlocked_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function achievement(): BelongsTo
{
return $this->belongsTo(Achievement::class, 'achievement_id');
}
}

39
app/Models/UserXpLog.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserXpLog extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'user_id',
'action',
'xp',
'reference_id',
'created_at',
];
protected function casts(): array
{
return [
'user_id' => 'integer',
'xp' => 'integer',
'reference_id' => 'integer',
'created_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\Achievement;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class AchievementUnlockedNotification extends Notification
{
use Queueable;
public function __construct(private readonly Achievement $achievement) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function databaseType(object $notifiable): string
{
return 'achievement_unlocked';
}
public function toDatabase(object $notifiable): array
{
return [
'type' => 'achievement_unlocked',
'achievement_id' => $this->achievement->id,
'achievement_slug' => $this->achievement->slug,
'title' => $this->achievement->name,
'icon' => $this->achievement->icon,
'message' => '🎉 You unlocked: ' . $this->achievement->name,
'xp_reward' => (int) $this->achievement->xp_reward,
'url' => '/dashboard?panel=achievements',
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
class ArtworkCommentedNotification extends Notification
{
use Queueable;
public function __construct(
private readonly Artwork $artwork,
private readonly ArtworkComment $comment,
private readonly User $actor,
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function databaseType(object $notifiable): string
{
return 'artwork_commented';
}
public function toDatabase(object $notifiable): array
{
$label = $this->actor->name ?: $this->actor->username ?: 'Someone';
$slug = Str::slug((string) ($this->artwork->slug ?: $this->artwork->title)) ?: (string) $this->artwork->id;
return [
'type' => 'artwork_commented',
'artwork_id' => (int) $this->artwork->id,
'artwork_title' => $this->artwork->title,
'comment_id' => (int) $this->comment->id,
'actor_id' => (int) $this->actor->id,
'actor_name' => $this->actor->name,
'actor_username' => $this->actor->username,
'message' => $label . ' commented on your artwork',
'url' => route('art.show', ['id' => $this->artwork->id, 'slug' => $slug]) . '#comment-' . $this->comment->id,
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
class ArtworkLikedNotification extends Notification
{
use Queueable;
public function __construct(
private readonly Artwork $artwork,
private readonly User $actor,
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function databaseType(object $notifiable): string
{
return 'artwork_liked';
}
public function toDatabase(object $notifiable): array
{
$label = $this->actor->name ?: $this->actor->username ?: 'Someone';
$slug = Str::slug((string) ($this->artwork->slug ?: $this->artwork->title)) ?: (string) $this->artwork->id;
return [
'type' => 'artwork_liked',
'artwork_id' => (int) $this->artwork->id,
'artwork_title' => $this->artwork->title,
'actor_id' => (int) $this->actor->id,
'actor_name' => $this->actor->name,
'actor_username' => $this->actor->username,
'message' => $label . ' liked your artwork',
'url' => route('art.show', ['id' => $this->artwork->id, 'slug' => $slug]),
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
class ArtworkMentionedNotification extends Notification
{
use Queueable;
public function __construct(
private readonly Artwork $artwork,
private readonly ArtworkComment $comment,
private readonly User $actor,
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function databaseType(object $notifiable): string
{
return 'artwork_mentioned';
}
public function toDatabase(object $notifiable): array
{
$label = $this->actor->name ?: $this->actor->username ?: 'Someone';
$slug = Str::slug((string) ($this->artwork->slug ?: $this->artwork->title)) ?: (string) $this->artwork->id;
return [
'type' => 'artwork_mentioned',
'artwork_id' => (int) $this->artwork->id,
'artwork_title' => $this->artwork->title,
'comment_id' => (int) $this->comment->id,
'actor_id' => (int) $this->actor->id,
'actor_name' => $this->actor->name,
'actor_username' => $this->actor->username,
'message' => $label . ' mentioned you in an artwork comment',
'url' => route('art.show', ['id' => $this->artwork->id, 'slug' => $slug]) . '#comment-' . $this->comment->id,
];
}
}

View File

@@ -24,6 +24,11 @@ class ArtworkSharedNotification extends Notification implements ShouldQueue
return ['database']; return ['database'];
} }
public function databaseType(object $notifiable): string
{
return 'artwork_shared';
}
public function toDatabase(object $notifiable): array public function toDatabase(object $notifiable): array
{ {
return [ return [
@@ -34,7 +39,7 @@ class ArtworkSharedNotification extends Notification implements ShouldQueue
'sharer_id' => $this->sharer->id, 'sharer_id' => $this->sharer->id,
'sharer_name' => $this->sharer->name, 'sharer_name' => $this->sharer->name,
'sharer_username' => $this->sharer->username, 'sharer_username' => $this->sharer->username,
'message' => "{$this->sharer->name} shared your artwork "{$this->artwork->title}"", 'message' => $this->sharer->name . ' shared your artwork "' . $this->artwork->title . '"',
'url' => "/@{$this->sharer->username}?tab=posts", 'url' => "/@{$this->sharer->username}?tab=posts",
]; ];
} }

View File

@@ -24,6 +24,11 @@ class PostCommentedNotification extends Notification implements ShouldQueue
return ['database']; return ['database'];
} }
public function databaseType(object $notifiable): string
{
return 'post_commented';
}
public function toDatabase(object $notifiable): array public function toDatabase(object $notifiable): array
{ {
return [ return [

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\Story;
use App\Models\StoryComment;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class StoryCommentedNotification extends Notification
{
use Queueable;
public function __construct(
private readonly Story $story,
private readonly StoryComment $comment,
private readonly User $actor,
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function databaseType(object $notifiable): string
{
return 'story_commented';
}
public function toDatabase(object $notifiable): array
{
$label = $this->actor->name ?: $this->actor->username ?: 'Someone';
return [
'type' => 'story_commented',
'story_id' => (int) $this->story->id,
'story_title' => $this->story->title,
'comment_id' => (int) $this->comment->id,
'actor_id' => (int) $this->actor->id,
'actor_name' => $this->actor->name,
'actor_username' => $this->actor->username,
'message' => $label . ' commented on your story',
'url' => route('stories.show', ['slug' => $this->story->slug]) . '#story-comment-' . $this->comment->id,
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\Story;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class StoryLikedNotification extends Notification
{
use Queueable;
public function __construct(
private readonly Story $story,
private readonly User $actor,
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function databaseType(object $notifiable): string
{
return 'story_liked';
}
public function toDatabase(object $notifiable): array
{
$label = $this->actor->name ?: $this->actor->username ?: 'Someone';
return [
'type' => 'story_liked',
'story_id' => (int) $this->story->id,
'story_title' => $this->story->title,
'actor_id' => (int) $this->actor->id,
'actor_name' => $this->actor->name,
'actor_username' => $this->actor->username,
'message' => $label . ' liked your story',
'url' => route('stories.show', ['slug' => $this->story->slug]),
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\Story;
use App\Models\StoryComment;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class StoryMentionedNotification extends Notification
{
use Queueable;
public function __construct(
private readonly Story $story,
private readonly StoryComment $comment,
private readonly User $actor,
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function databaseType(object $notifiable): string
{
return 'story_mentioned';
}
public function toDatabase(object $notifiable): array
{
$label = $this->actor->name ?: $this->actor->username ?: 'Someone';
return [
'type' => 'story_mentioned',
'story_id' => (int) $this->story->id,
'story_title' => $this->story->title,
'comment_id' => (int) $this->comment->id,
'actor_id' => (int) $this->actor->id,
'actor_name' => $this->actor->name,
'actor_username' => $this->actor->username,
'message' => $label . ' mentioned you in a story comment',
'url' => route('stories.show', ['slug' => $this->story->slug]) . '#story-comment-' . $this->comment->id,
];
}
}

View File

@@ -24,6 +24,13 @@ class StoryStatusNotification extends Notification
return ['database']; return ['database'];
} }
public function databaseType(object $notifiable): string
{
return in_array($this->event, ['approved', 'published'], true)
? 'story_published'
: 'story_status';
}
public function toDatabase(object $notifiable): array public function toDatabase(object $notifiable): array
{ {
$message = match ($this->event) { $message = match ($this->event) {
@@ -34,7 +41,9 @@ class StoryStatusNotification extends Notification
}; };
return [ return [
'type' => 'story.' . $this->event, 'type' => in_array($this->event, ['approved', 'published'], true)
? 'story_published'
: 'story.' . $this->event,
'story_id' => $this->story->id, 'story_id' => $this->story->id,
'title' => $this->story->title, 'title' => $this->story->title,
'slug' => $this->story->slug, 'slug' => $this->story->slug,

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class UserFollowedNotification extends Notification
{
use Queueable;
public function __construct(private readonly User $actor) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function databaseType(object $notifiable): string
{
return 'user_followed';
}
public function toDatabase(object $notifiable): array
{
$label = $this->actor->name ?: $this->actor->username ?: 'Someone';
return [
'type' => 'user_followed',
'actor_id' => (int) $this->actor->id,
'actor_name' => $this->actor->name,
'actor_username' => $this->actor->username,
'message' => $label . ' started following you',
'url' => $this->actor->username ? '/@' . $this->actor->username : null,
];
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Observers;
use App\Models\ArtworkComment; use App\Models\ArtworkComment;
use App\Services\UserStatsService; use App\Services\UserStatsService;
use App\Services\UserMentionSyncService; use App\Services\UserMentionSyncService;
use App\Services\XPService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
/** /**
@@ -18,6 +19,7 @@ class ArtworkCommentObserver
public function __construct( public function __construct(
private readonly UserStatsService $userStats, private readonly UserStatsService $userStats,
private readonly UserMentionSyncService $mentionSync, private readonly UserMentionSyncService $mentionSync,
private readonly XPService $xp,
) {} ) {}
public function created(ArtworkComment $comment): void public function created(ArtworkComment $comment): void
@@ -30,6 +32,7 @@ class ArtworkCommentObserver
// The commenter is "active" // The commenter is "active"
$this->userStats->ensureRow($comment->user_id); $this->userStats->ensureRow($comment->user_id);
$this->userStats->setLastActiveAt($comment->user_id); $this->userStats->setLastActiveAt($comment->user_id);
$this->xp->awardCommentCreated((int) $comment->user_id, (int) $comment->id, 'artwork');
$this->mentionSync->syncForComment($comment); $this->mentionSync->syncForComment($comment);
} }

View File

@@ -4,12 +4,14 @@ declare(strict_types=1);
namespace App\Observers; namespace App\Observers;
use App\Events\Achievements\AchievementCheckRequested;
use App\Models\Artwork; use App\Models\Artwork;
use App\Jobs\RecComputeSimilarByTagsJob; use App\Jobs\RecComputeSimilarByTagsJob;
use App\Jobs\RecComputeSimilarHybridJob; use App\Jobs\RecComputeSimilarHybridJob;
use App\Jobs\Posts\AutoUploadPostJob; use App\Jobs\Posts\AutoUploadPostJob;
use App\Services\ArtworkSearchIndexer; use App\Services\ArtworkSearchIndexer;
use App\Services\UserStatsService; use App\Services\UserStatsService;
use App\Services\XPService;
/** /**
* Syncs artwork documents to Meilisearch on every relevant model event. * Syncs artwork documents to Meilisearch on every relevant model event.
@@ -22,6 +24,7 @@ class ArtworkObserver
public function __construct( public function __construct(
private readonly ArtworkSearchIndexer $indexer, private readonly ArtworkSearchIndexer $indexer,
private readonly UserStatsService $userStats, private readonly UserStatsService $userStats,
private readonly XPService $xp,
) {} ) {}
/** New artwork created — index; bump uploadscount + last_upload_at. */ /** New artwork created — index; bump uploadscount + last_upload_at. */
@@ -30,6 +33,11 @@ class ArtworkObserver
$this->indexer->index($artwork); $this->indexer->index($artwork);
$this->userStats->incrementUploads($artwork->user_id); $this->userStats->incrementUploads($artwork->user_id);
$this->userStats->setLastUploadAt($artwork->user_id, $artwork->created_at); $this->userStats->setLastUploadAt($artwork->user_id, $artwork->created_at);
if ($artwork->published_at !== null) {
$this->xp->awardArtworkPublished((int) $artwork->user_id, (int) $artwork->id);
event(new AchievementCheckRequested((int) $artwork->user_id));
}
} }
/** Artwork updated — covers publish, approval, metadata changes. */ /** Artwork updated — covers publish, approval, metadata changes. */
@@ -52,6 +60,9 @@ class ArtworkObserver
// Auto-upload post: fire only when artwork transitions to published for the first time // Auto-upload post: fire only when artwork transitions to published for the first time
if ($artwork->wasChanged('published_at') && $artwork->published_at !== null) { if ($artwork->wasChanged('published_at') && $artwork->published_at !== null) {
$this->xp->awardArtworkPublished((int) $artwork->user_id, (int) $artwork->id);
event(new AchievementCheckRequested((int) $artwork->user_id));
$user = $artwork->user; $user = $artwork->user;
$autoPost = $user?->profile?->auto_post_upload ?? true; $autoPost = $user?->profile?->auto_post_upload ?? true;
if ($autoPost) { if ($autoPost) {

View File

@@ -24,6 +24,8 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Queue\Events\JobFailed; use Illuminate\Queue\Events\JobFailed;
use App\Services\ReceivedCommentsInboxService;
use Klevze\ControlPanel\Framework\Core\Menu;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -32,6 +34,11 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
$this->app->singleton(
\App\Services\Countries\CountryRemoteProviderInterface::class,
\App\Services\Countries\CountryRemoteProvider::class,
);
// Bind UploadDraftService interface to implementation // Bind UploadDraftService interface to implementation
$this->app->singleton(UploadDraftServiceInterface::class, function ($app) { $this->app->singleton(UploadDraftServiceInterface::class, function ($app) {
return new UploadDraftService($app->make('filesystem')); return new UploadDraftService($app->make('filesystem'));
@@ -55,6 +62,8 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
$this->registerCpadMenuItems();
// Map the 'legacy' view namespace to resources/views/_legacy so all // Map the 'legacy' view namespace to resources/views/_legacy so all
// view('legacy::foo') and @include('legacy::foo') calls resolve correctly // view('legacy::foo') and @include('legacy::foo') calls resolve correctly
// after the folder was renamed from legacy/ to _legacy/. // after the folder was renamed from legacy/ to _legacy/.
@@ -66,6 +75,7 @@ class AppServiceProvider extends ServiceProvider
$this->configureDownloadRateLimiter(); $this->configureDownloadRateLimiter();
$this->configureArtworkRateLimiters(); $this->configureArtworkRateLimiters();
$this->configureReactionRateLimiters(); $this->configureReactionRateLimiters();
$this->configureSocialRateLimiters();
$this->configureSettingsRateLimiters(); $this->configureSettingsRateLimiters();
$this->configureMailFailureLogging(); $this->configureMailFailureLogging();
@@ -91,10 +101,22 @@ class AppServiceProvider extends ServiceProvider
\App\Events\Posts\PostCommented::class, \App\Events\Posts\PostCommented::class,
\App\Listeners\Posts\SendPostCommentedNotification::class, \App\Listeners\Posts\SendPostCommentedNotification::class,
); );
Event::listen(
\App\Events\Posts\PostCommented::class,
\App\Listeners\Posts\AwardXpForPostCommented::class,
);
Event::listen(
\App\Events\Achievements\AchievementCheckRequested::class,
\App\Listeners\Achievements\CheckUserAchievements::class,
);
Event::listen(
\App\Events\Achievements\UserXpUpdated::class,
\App\Listeners\Achievements\CheckUserAchievements::class,
);
// Provide toolbar counts and user info to layout views (port of legacy toolbar logic) // Provide toolbar counts and user info to layout views (port of legacy toolbar logic)
View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) { View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) {
$uploadCount = $favCount = $msgCount = $noticeCount = 0; $uploadCount = $favCount = $msgCount = $noticeCount = $receivedCommentsCount = 0;
$avatarHash = null; $avatarHash = null;
$displayName = null; $displayName = null;
$userId = null; $userId = null;
@@ -130,11 +152,18 @@ class AppServiceProvider extends ServiceProvider
} }
try { try {
$noticeCount = DB::table('notification')->where('user_id', $userId)->where('new', 1)->count(); $noticeCount = DB::table('notifications')->where('user_id', $userId)->whereNull('read_at')->count();
} catch (\Throwable $e) { } catch (\Throwable $e) {
$noticeCount = 0; $noticeCount = 0;
} }
try {
$receivedCommentsCount = $this->app->make(ReceivedCommentsInboxService::class)
->unreadCountForUser(Auth::user());
} catch (\Throwable $e) {
$receivedCommentsCount = 0;
}
try { try {
$profile = DB::table('user_profiles')->where('user_id', $userId)->first(); $profile = DB::table('user_profiles')->where('user_id', $userId)->first();
$avatarHash = $profile->avatar_hash ?? null; $avatarHash = $profile->avatar_hash ?? null;
@@ -145,7 +174,7 @@ class AppServiceProvider extends ServiceProvider
$displayName = Auth::user()->name ?: (Auth::user()->username ?? ''); $displayName = Auth::user()->name ?: (Auth::user()->username ?? '');
} }
$view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'avatarHash', 'displayName')); $view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'receivedCommentsCount', 'avatarHash', 'displayName'));
}); });
// Replace the framework HandleCors with our ConditionalCors so the // Replace the framework HandleCors with our ConditionalCors so the
@@ -315,6 +344,27 @@ class AppServiceProvider extends ServiceProvider
}); });
} }
private function configureSocialRateLimiters(): void
{
RateLimiter::for('social-write', function (Request $request): array {
$userId = $request->user()?->id ?? 'guest';
return [
Limit::perMinute(60)->by('social-write:user:' . $userId),
Limit::perMinute(120)->by('social-write:ip:' . $request->ip()),
];
});
RateLimiter::for('social-read', function (Request $request): array {
$userId = $request->user()?->id ?? 'guest';
return [
Limit::perMinute(240)->by('social-read:user:' . $userId),
Limit::perMinute(480)->by('social-read:ip:' . $request->ip()),
];
});
}
private function configureSettingsRateLimiters(): void private function configureSettingsRateLimiters(): void
{ {
RateLimiter::for('username-check', function (Request $request): Limit { RateLimiter::for('username-check', function (Request $request): Limit {
@@ -336,4 +386,20 @@ class AppServiceProvider extends ServiceProvider
return Limit::perHour(1)->by($key); return Limit::perHour(1)->by($key);
}); });
} }
private function registerCpadMenuItems(): void
{
if (! class_exists(Menu::class)) {
return;
}
try {
/** @var Menu $menu */
$menu = $this->app->make(Menu::class);
$menu->addHeaderItem('Countries', 'fa-solid fa-flag', 'admin.cp.countries.main');
$menu->addItem('Users', 'Countries', 'fa-solid fa-flag', 'admin.cp.countries.main');
} catch (\Throwable) {
// Control panel menu registration should never block the app boot.
}
}
} }

View File

@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Achievement;
use App\Models\Artwork;
use App\Models\Story;
use App\Models\User;
use App\Models\UserAchievement;
use App\Notifications\AchievementUnlockedNotification;
use App\Services\Posts\PostAchievementService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class AchievementService
{
public function __construct(
private readonly XPService $xp,
private readonly PostAchievementService $achievementPosts,
) {}
public function checkAchievements(User|int $user): array
{
$currentUser = $this->resolveUser($user);
$unlocked = [];
foreach ($this->unlockableDefinitions($currentUser) as $achievement) {
if ($this->unlockAchievement($currentUser, $achievement)) {
$unlocked[] = $achievement->slug;
}
}
$this->forgetSummaryCache((int) $currentUser->id);
return $unlocked;
}
public function previewUnlocks(User|int $user): array
{
$currentUser = $this->resolveUser($user);
return $this->unlockableDefinitions($currentUser)
->pluck('slug')
->values()
->all();
}
public function unlockAchievement(User|int $user, Achievement|int $achievement): bool
{
$currentUser = $user instanceof User ? $user : User::query()->findOrFail($user);
$currentAchievement = $achievement instanceof Achievement
? $achievement
: Achievement::query()->findOrFail($achievement);
$inserted = false;
DB::transaction(function () use ($currentUser, $currentAchievement, &$inserted): void {
$result = UserAchievement::query()->insertOrIgnore([
'user_id' => (int) $currentUser->id,
'achievement_id' => (int) $currentAchievement->id,
'unlocked_at' => now(),
]);
if ($result === 0) {
return;
}
$inserted = true;
});
if (! $inserted) {
return false;
}
if ((int) $currentAchievement->xp_reward > 0) {
$this->xp->addXP(
(int) $currentUser->id,
(int) $currentAchievement->xp_reward,
'achievement_unlocked:' . $currentAchievement->slug,
(int) $currentAchievement->id,
false,
);
}
$currentUser->notify(new AchievementUnlockedNotification($currentAchievement));
$this->achievementPosts->achievementUnlocked($currentUser, $currentAchievement);
$this->forgetSummaryCache((int) $currentUser->id);
return true;
}
public function hasAchievement(User|int $user, string $achievementSlug): bool
{
$userId = $user instanceof User ? (int) $user->id : $user;
return UserAchievement::query()
->where('user_id', $userId)
->whereHas('achievement', fn ($query) => $query->where('slug', $achievementSlug))
->exists();
}
public function summary(User|int $user): array
{
$userId = $user instanceof User ? (int) $user->id : $user;
return Cache::remember($this->summaryCacheKey($userId), now()->addMinutes(10), function () use ($userId): array {
$currentUser = User::query()->with('statistics')->findOrFail($userId);
$progress = $this->progressSnapshot($currentUser);
$unlockedMap = UserAchievement::query()
->where('user_id', $userId)
->get()
->keyBy('achievement_id');
$items = $this->definitions()->map(function (Achievement $achievement) use ($progress, $unlockedMap): array {
$progressValue = $this->progressValue($progress, $achievement);
/** @var UserAchievement|null $unlocked */
$unlocked = $unlockedMap->get($achievement->id);
return [
'id' => (int) $achievement->id,
'name' => $achievement->name,
'slug' => $achievement->slug,
'description' => $achievement->description,
'icon' => $achievement->icon,
'xp_reward' => (int) $achievement->xp_reward,
'type' => $achievement->type,
'condition_type' => $achievement->condition_type,
'condition_value' => (int) $achievement->condition_value,
'progress' => min((int) $achievement->condition_value, $progressValue),
'progress_percent' => $achievement->condition_value > 0
? (int) round((min((int) $achievement->condition_value, $progressValue) / (int) $achievement->condition_value) * 100)
: 100,
'unlocked' => $unlocked !== null,
'unlocked_at' => $unlocked?->unlocked_at?->toIso8601String(),
];
});
return [
'unlocked' => $items->where('unlocked', true)->sortByDesc('unlocked_at')->values()->all(),
'locked' => $items->where('unlocked', false)->values()->all(),
'recent' => $items->where('unlocked', true)->sortByDesc('unlocked_at')->take(4)->values()->all(),
'counts' => [
'total' => $items->count(),
'unlocked' => $items->where('unlocked', true)->count(),
'locked' => $items->where('unlocked', false)->count(),
],
];
});
}
public function definitions()
{
return Cache::remember('achievements:definitions', now()->addHour(), function () {
return Achievement::query()->orderBy('type')->orderBy('condition_value')->get();
});
}
public function forgetDefinitionsCache(): void
{
Cache::forget('achievements:definitions');
}
private function progressValue(array $progress, Achievement $achievement): int
{
return (int) ($progress[$achievement->condition_type] ?? 0);
}
private function resolveUser(User|int $user): User
{
return $user instanceof User
? $user->loadMissing('statistics')
: User::query()->with('statistics')->findOrFail($user);
}
private function unlockableDefinitions(User $user): Collection
{
$progress = $this->progressSnapshot($user);
$unlockedSlugs = $this->unlockedSlugs((int) $user->id);
return $this->definitions()->filter(function (Achievement $achievement) use ($progress, $unlockedSlugs): bool {
if ($this->progressValue($progress, $achievement) < (int) $achievement->condition_value) {
return false;
}
return ! isset($unlockedSlugs[$achievement->slug]);
})->values();
}
private function progressSnapshot(User $user): array
{
return [
'upload_count' => Artwork::query()
->published()
->where('user_id', $user->id)
->count(),
'likes_received' => (int) DB::table('artwork_likes as likes')
->join('artworks as artworks', 'artworks.id', '=', 'likes.artwork_id')
->where('artworks.user_id', $user->id)
->count(),
'followers_count' => (int) ($user->statistics?->followers_count ?? $user->followers()->count()),
'stories_published' => Story::query()->published()->where('creator_id', $user->id)->count(),
'level_reached' => (int) ($user->level ?? 1),
];
}
private function unlockedSlugs(int $userId): array
{
return UserAchievement::query()
->where('user_id', $userId)
->join('achievements', 'achievements.id', '=', 'user_achievements.achievement_id')
->pluck('achievements.slug')
->flip()
->all();
}
private function forgetSummaryCache(int $userId): void
{
Cache::forget($this->summaryCacheKey($userId));
}
private function summaryCacheKey(int $userId): string
{
return 'achievements:summary:' . $userId;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\ActivityEvent;
use App\Models\User;
final class ActivityService
{
public function __construct(private readonly CommunityActivityService $communityActivity) {}
public function record(int $actorId, string $type, string $targetType, int $targetId, array $meta = []): void
{
ActivityEvent::record(
actorId: $actorId,
type: $type,
targetType: $targetType,
targetId: $targetId,
meta: $meta,
);
}
public function communityFeed(?User $viewer, string $filter = 'all', int $page = 1, int $perPage = CommunityActivityService::DEFAULT_PER_PAGE, ?int $actorUserId = null): array
{
return $this->communityActivity->getFeed($viewer, $filter, $page, $perPage, $actorUserId);
}
public function requiresAuthentication(string $filter): bool
{
return $this->communityActivity->requiresAuthentication($filter);
}
}

View File

@@ -5,9 +5,11 @@ declare(strict_types=1);
namespace App\Services; namespace App\Services;
use App\Enums\ReactionType; use App\Enums\ReactionType;
use App\Models\ActivityEvent;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\ArtworkComment; use App\Models\ArtworkComment;
use App\Models\CommentReaction; use App\Models\CommentReaction;
use App\Models\Story;
use App\Models\User; use App\Models\User;
use App\Models\UserMention; use App\Models\UserMention;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
@@ -74,13 +76,15 @@ final class CommunityActivityService
$commentModels = $this->fetchCommentModels($sourceLimit, repliesOnly: false); $commentModels = $this->fetchCommentModels($sourceLimit, repliesOnly: false);
$replyModels = $this->fetchCommentModels($sourceLimit, repliesOnly: true); $replyModels = $this->fetchCommentModels($sourceLimit, repliesOnly: true);
$reactionModels = $this->fetchReactionModels($sourceLimit); $reactionModels = $this->fetchReactionModels($sourceLimit);
$recordedActivities = $this->fetchRecordedActivities($sourceLimit);
$commentActivities = $commentModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'comment')); $commentActivities = $commentModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'comment'));
$replyActivities = $replyModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'reply')); $replyActivities = $replyModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'reply'));
$reactionActivities = $reactionModels->map(fn (CommentReaction $reaction) => $this->mapReactionActivity($reaction)); $reactionActivities = $reactionModels->map(fn (CommentReaction $reaction) => $this->mapReactionActivity($reaction));
$mentionActivities = $this->fetchMentionActivities($sourceLimit); $mentionActivities = $this->fetchMentionActivities($sourceLimit);
$merged = $commentActivities $merged = $recordedActivities
->concat($commentActivities)
->concat($replyActivities) ->concat($replyActivities)
->concat($reactionActivities) ->concat($reactionActivities)
->concat($mentionActivities) ->concat($mentionActivities)
@@ -136,6 +140,89 @@ final class CommunityActivityService
]; ];
} }
private function fetchRecordedActivities(int $limit): Collection
{
$events = ActivityEvent::query()
->select(['id', 'actor_id', 'type', 'target_type', 'target_id', 'meta', 'created_at'])
->with([
'actor' => function ($query) {
$query
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
->with('profile:user_id,avatar_hash')
->withCount('artworks');
},
])
->whereHas('actor', fn ($query) => $query->where('is_active', true)->whereNull('deleted_at'))
->latest('created_at')
->limit($limit)
->get();
if ($events->isEmpty()) {
return collect();
}
$artworkIds = $events
->where('target_type', ActivityEvent::TARGET_ARTWORK)
->pluck('target_id')
->map(fn ($id) => (int) $id)
->unique()
->values()
->all();
$storyIds = $events
->where('target_type', ActivityEvent::TARGET_STORY)
->pluck('target_id')
->map(fn ($id) => (int) $id)
->unique()
->values()
->all();
$targetUserIds = $events
->where('target_type', ActivityEvent::TARGET_USER)
->pluck('target_id')
->map(fn ($id) => (int) $id)
->unique()
->values()
->all();
$artworks = empty($artworkIds)
? collect()
: Artwork::query()
->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved')
->whereIn('id', $artworkIds)
->public()
->published()
->whereNull('deleted_at')
->get()
->keyBy('id');
$stories = empty($storyIds)
? collect()
: Story::query()
->select('id', 'creator_id', 'title', 'slug', 'cover_image', 'published_at', 'status')
->whereIn('id', $storyIds)
->published()
->get()
->keyBy('id');
$targetUsers = empty($targetUserIds)
? collect()
: User::query()
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
->with('profile:user_id,avatar_hash')
->withCount('artworks')
->whereIn('id', $targetUserIds)
->where('is_active', true)
->whereNull('deleted_at')
->get()
->keyBy('id');
return $events
->map(fn (ActivityEvent $event) => $this->mapRecordedActivity($event, $artworks, $stories, $targetUsers))
->filter()
->values();
}
private function fetchCommentModels(int $limit, bool $repliesOnly): Collection private function fetchCommentModels(int $limit, bool $repliesOnly): Collection
{ {
return ArtworkComment::query() return ArtworkComment::query()
@@ -262,6 +349,52 @@ final class CommunityActivityService
]; ];
} }
private function mapRecordedActivity(ActivityEvent $event, Collection $artworks, Collection $stories, Collection $targetUsers): ?array
{
if ($event->type === ActivityEvent::TYPE_COMMENT && $event->target_type === ActivityEvent::TARGET_ARTWORK) {
return null;
}
$artwork = $event->target_type === ActivityEvent::TARGET_ARTWORK
? $artworks->get((int) $event->target_id)
: null;
$story = $event->target_type === ActivityEvent::TARGET_STORY
? $stories->get((int) $event->target_id)
: null;
$targetUser = $event->target_type === ActivityEvent::TARGET_USER
? $targetUsers->get((int) $event->target_id)
: null;
if ($event->target_type === ActivityEvent::TARGET_ARTWORK && ! $artwork) {
return null;
}
if ($event->target_type === ActivityEvent::TARGET_STORY && ! $story) {
return null;
}
if ($event->target_type === ActivityEvent::TARGET_USER && ! $targetUser) {
return null;
}
$iso = $event->created_at?->toIso8601String();
return [
'id' => 'event:' . $event->id,
'type' => (string) $event->type,
'user' => $this->buildUserPayload($event->actor),
'artwork' => $this->buildArtworkPayload($artwork),
'story' => $this->buildStoryPayload($story),
'target_user' => $this->buildUserPayload($targetUser),
'meta' => is_array($event->meta) ? $event->meta : [],
'created_at' => $iso,
'time_ago' => $event->created_at?->diffForHumans(),
'sort_timestamp' => $iso,
];
}
private function fetchMentionActivities(int $limit): Collection private function fetchMentionActivities(int $limit): Collection
{ {
if (! Schema::hasTable('user_mentions')) { if (! Schema::hasTable('user_mentions')) {
@@ -384,6 +517,20 @@ final class CommunityActivityService
]; ];
} }
private function buildStoryPayload(?Story $story): ?array
{
if (! $story) {
return null;
}
return [
'id' => (int) $story->id,
'title' => html_entity_decode((string) ($story->title ?? 'Story'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => route('stories.show', ['slug' => $story->slug]),
'cover_url' => $story->cover_url,
];
}
private function buildCommentPayload(ArtworkComment $comment): array private function buildCommentPayload(ArtworkComment $comment): array
{ {
$artwork = $this->buildArtworkPayload($comment->artwork); $artwork = $this->buildArtworkPayload($comment->artwork);

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Services\Countries;
use App\Models\Country;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
final class CountryCatalogService
{
public const ACTIVE_ALL_CACHE_KEY = 'countries.active.all';
public const PROFILE_SELECT_CACHE_KEY = 'countries.profile.select';
/**
* @return Collection<int, Country>
*/
public function activeCountries(): Collection
{
if (! Schema::hasTable('countries')) {
return collect();
}
/** @var Collection<int, Country> $countries */
$countries = Cache::remember(
self::ACTIVE_ALL_CACHE_KEY,
max(60, (int) config('skinbase-countries.cache_ttl', 86400)),
fn (): Collection => Country::query()->active()->ordered()->get(),
);
return $countries;
}
/**
* @return array<int, array<string, mixed>>
*/
public function profileSelectOptions(): array
{
return Cache::remember(
self::PROFILE_SELECT_CACHE_KEY,
max(60, (int) config('skinbase-countries.cache_ttl', 86400)),
fn (): array => $this->activeCountries()
->map(fn (Country $country): array => [
'id' => $country->id,
'iso2' => $country->iso2,
'name' => $country->name_common,
'flag_emoji' => $country->flag_emoji,
'flag_css_class' => $country->flag_css_class,
'is_featured' => $country->is_featured,
'flag_path' => $country->local_flag_path,
])
->values()
->all(),
);
}
public function findById(?int $countryId): ?Country
{
if ($countryId === null || $countryId <= 0 || ! Schema::hasTable('countries')) {
return null;
}
return Country::query()->find($countryId);
}
public function findByIso2(?string $iso2): ?Country
{
$normalized = strtoupper(trim((string) $iso2));
if ($normalized === '' || ! preg_match('/^[A-Z]{2}$/', $normalized) || ! Schema::hasTable('countries')) {
return null;
}
return Country::query()->where('iso2', $normalized)->first();
}
public function resolveUserCountry(User $user): ?Country
{
if ($user->relationLoaded('country') && $user->country instanceof Country) {
return $user->country;
}
if (! empty($user->country_id)) {
return $this->findById((int) $user->country_id);
}
$countryCode = strtoupper((string) ($user->profile?->country_code ?? ''));
return $countryCode !== '' ? $this->findByIso2($countryCode) : null;
}
public function flushCache(): void
{
Cache::forget(self::ACTIVE_ALL_CACHE_KEY);
Cache::forget(self::PROFILE_SELECT_CACHE_KEY);
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Services\Countries;
use Illuminate\Http\Client\Factory as HttpFactory;
use RuntimeException;
final class CountryRemoteProvider implements CountryRemoteProviderInterface
{
public function __construct(
private readonly HttpFactory $http,
) {
}
public function fetchAll(): array
{
$endpoint = trim((string) config('skinbase-countries.endpoint', ''));
if ($endpoint === '') {
throw new RuntimeException('Country sync endpoint is not configured.');
}
$response = $this->http->acceptJson()
->connectTimeout(max(1, (int) config('skinbase-countries.connect_timeout', 5)))
->timeout(max(1, (int) config('skinbase-countries.timeout', 10)))
->retry(
max(0, (int) config('skinbase-countries.retry_times', 2)),
max(0, (int) config('skinbase-countries.retry_sleep_ms', 250)),
throw: false,
)
->get($endpoint);
if (! $response->successful()) {
throw new RuntimeException(sprintf('Country sync request failed with status %d.', $response->status()));
}
$payload = $response->json();
if (! is_array($payload)) {
throw new RuntimeException('Country sync response was not a JSON array.');
}
return $this->normalizePayload($payload);
}
public function normalizePayload(array $payload): array
{
$normalized = [];
foreach ($payload as $record) {
if (! is_array($record)) {
continue;
}
$country = $this->normalizeRecord($record);
if ($country !== null) {
$normalized[] = $country;
}
}
return $normalized;
}
/**
* @param array<string, mixed> $record
* @return array<string, mixed>|null
*/
private function normalizeRecord(array $record): ?array
{
$iso2 = strtoupper(trim((string) ($record['cca2'] ?? $record['iso2'] ?? '')));
if (! preg_match('/^[A-Z]{2}$/', $iso2)) {
return null;
}
$iso3 = strtoupper(trim((string) ($record['cca3'] ?? $record['iso3'] ?? '')));
$iso3 = preg_match('/^[A-Z]{3}$/', $iso3) ? $iso3 : null;
$numericCode = trim((string) ($record['ccn3'] ?? $record['numeric_code'] ?? ''));
$numericCode = preg_match('/^\d{1,3}$/', $numericCode)
? str_pad($numericCode, 3, '0', STR_PAD_LEFT)
: null;
$name = $record['name'] ?? [];
$nameCommon = trim((string) ($name['common'] ?? $record['name_common'] ?? ''));
if ($nameCommon === '') {
return null;
}
$nameOfficial = trim((string) ($name['official'] ?? $record['name_official'] ?? ''));
$flags = $record['flags'] ?? [];
$flagSvgUrl = trim((string) ($flags['svg'] ?? $record['flag_svg_url'] ?? ''));
$flagPngUrl = trim((string) ($flags['png'] ?? $record['flag_png_url'] ?? ''));
$flagEmoji = trim((string) ($record['flag'] ?? $record['flag_emoji'] ?? ''));
$region = trim((string) ($record['region'] ?? ''));
$subregion = trim((string) ($record['subregion'] ?? ''));
return [
'iso2' => $iso2,
'iso3' => $iso3,
'numeric_code' => $numericCode,
'name_common' => $nameCommon,
'name_official' => $nameOfficial !== '' ? $nameOfficial : null,
'region' => $region !== '' ? $region : null,
'subregion' => $subregion !== '' ? $subregion : null,
'flag_svg_url' => $flagSvgUrl !== '' ? $flagSvgUrl : null,
'flag_png_url' => $flagPngUrl !== '' ? $flagPngUrl : null,
'flag_emoji' => $flagEmoji !== '' ? $flagEmoji : null,
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Services\Countries;
interface CountryRemoteProviderInterface
{
/**
* Fetch and normalize all remote countries.
*
* @return array<int, array<string, mixed>>
*/
public function fetchAll(): array;
/**
* Normalize a raw payload into syncable country records.
*
* @param array<int, mixed> $payload
* @return array<int, array<string, mixed>>
*/
public function normalizePayload(array $payload): array;
}

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Services\Countries;
use App\Models\Country;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use JsonException;
use RuntimeException;
use Throwable;
final class CountrySyncService
{
public function __construct(
private readonly CountryRemoteProviderInterface $remoteProvider,
private readonly CountryCatalogService $catalog,
) {
}
/**
* @return array<string, int|string|null>
*/
public function sync(bool $allowFallback = true, ?bool $deactivateMissing = null): array
{
if (! (bool) config('skinbase-countries.enabled', true)) {
throw new RuntimeException('Countries sync is disabled by configuration.');
}
$summary = [
'source' => null,
'total_fetched' => 0,
'inserted' => 0,
'updated' => 0,
'skipped' => 0,
'invalid' => 0,
'deactivated' => 0,
'backfilled_users' => 0,
];
try {
$records = $this->remoteProvider->fetchAll();
$summary['source'] = (string) config('skinbase-countries.remote_source', 'remote');
} catch (Throwable $exception) {
if (! $allowFallback || ! (bool) config('skinbase-countries.fallback_seed_enabled', true)) {
throw new RuntimeException('Country sync failed: '.$exception->getMessage(), previous: $exception);
}
$records = $this->loadFallbackRecords();
$summary['source'] = 'fallback';
}
if ($records === []) {
throw new RuntimeException('Country sync did not yield any valid country records.');
}
$summary['total_fetched'] = count($records);
$seenIso2 = [];
$featured = array_values(array_filter(array_map(
static fn (mixed $iso2): string => strtoupper(trim((string) $iso2)),
(array) config('skinbase-countries.featured_countries', []),
)));
$featuredOrder = array_flip($featured);
DB::transaction(function () use (&$summary, $records, &$seenIso2, $featuredOrder, $deactivateMissing): void {
foreach ($records as $record) {
$iso2 = strtoupper((string) ($record['iso2'] ?? ''));
if (! preg_match('/^[A-Z]{2}$/', $iso2)) {
$summary['invalid']++;
continue;
}
if (isset($seenIso2[$iso2])) {
$summary['skipped']++;
continue;
}
$seenIso2[$iso2] = true;
$country = Country::query()->firstOrNew(['iso2' => $iso2]);
$exists = $country->exists;
$featuredIndex = $featuredOrder[$iso2] ?? null;
$country->fill([
'iso' => $iso2,
'iso3' => $record['iso3'] ?? null,
'numeric_code' => $record['numeric_code'] ?? null,
'name' => $record['name_common'],
'native' => $record['name_official'] ?? null,
'continent' => $this->continentCode($record['region'] ?? null),
'name_common' => $record['name_common'],
'name_official' => $record['name_official'] ?? null,
'region' => $record['region'] ?? null,
'subregion' => $record['subregion'] ?? null,
'flag_svg_url' => $record['flag_svg_url'] ?? null,
'flag_png_url' => $record['flag_png_url'] ?? null,
'flag_emoji' => $record['flag_emoji'] ?? null,
'active' => true,
'is_featured' => $featuredIndex !== null,
'sort_order' => $featuredIndex !== null ? $featuredIndex + 1 : 1000,
]);
if (! $exists) {
$country->save();
$summary['inserted']++;
continue;
}
if ($country->isDirty()) {
$country->save();
$summary['updated']++;
continue;
}
$summary['skipped']++;
}
if ($deactivateMissing ?? (bool) config('skinbase-countries.deactivate_missing', false)) {
$summary['deactivated'] = Country::query()
->where('active', true)
->whereNotIn('iso2', array_keys($seenIso2))
->update(['active' => false]);
}
});
$summary['backfilled_users'] = $this->backfillUsersFromLegacyProfileCodes();
$this->catalog->flushCache();
return $summary;
}
/**
* @return array<int, array<string, mixed>>
*/
private function loadFallbackRecords(): array
{
$path = (string) config('skinbase-countries.fallback_seed_path', database_path('data/countries-fallback.json'));
if (! is_file($path)) {
throw new RuntimeException('Country fallback dataset is missing.');
}
try {
$decoded = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new RuntimeException('Country fallback dataset is invalid JSON.', previous: $exception);
}
if (! is_array($decoded)) {
throw new RuntimeException('Country fallback dataset is not a JSON array.');
}
return $this->remoteProvider->normalizePayload($decoded);
}
private function backfillUsersFromLegacyProfileCodes(): int
{
if (! Schema::hasTable('user_profiles') || ! Schema::hasTable('users') || ! Schema::hasColumn('users', 'country_id')) {
return 0;
}
$rows = DB::table('users as users')
->join('user_profiles as profiles', 'profiles.user_id', '=', 'users.id')
->join('countries as countries', 'countries.iso2', '=', 'profiles.country_code')
->whereNull('users.country_id')
->whereNotNull('profiles.country_code')
->select(['users.id as user_id', 'countries.id as country_id'])
->get();
foreach ($rows as $row) {
DB::table('users')
->where('id', (int) $row->user_id)
->update(['country_id' => (int) $row->country_id]);
}
return $rows->count();
}
private function continentCode(?string $region): ?string
{
return Arr::get([
'Africa' => 'AF',
'Americas' => 'AM',
'Asia' => 'AS',
'Europe' => 'EU',
'Oceania' => 'OC',
'Antarctic' => 'AN',
], trim((string) $region));
}
}

View File

@@ -2,6 +2,9 @@
namespace App\Services; namespace App\Services;
use App\Models\User;
use App\Notifications\UserFollowedNotification;
use App\Events\Achievements\AchievementCheckRequested;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
/** /**
@@ -16,6 +19,8 @@ use Illuminate\Support\Facades\DB;
*/ */
final class FollowService final class FollowService
{ {
public function __construct(private readonly XPService $xp) {}
/** /**
* Follow $targetId on behalf of $actorId. * Follow $targetId on behalf of $actorId.
* *
@@ -60,6 +65,15 @@ final class FollowService
targetId: $targetId, targetId: $targetId,
); );
} catch (\Throwable) {} } catch (\Throwable) {}
$targetUser = User::query()->find($targetId);
$actorUser = User::query()->find($actorId);
if ($targetUser && $actorUser) {
$targetUser->notify(new UserFollowedNotification($actorUser));
}
$this->xp->awardFollowerReceived($targetId, $actorId);
event(new AchievementCheckRequested($targetId));
} }
return $inserted; return $inserted;

View File

@@ -0,0 +1,553 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\ArtworkMetricSnapshotHourly;
use App\Models\Leaderboard;
use App\Models\Story;
use App\Models\StoryLike;
use App\Models\StoryView;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class LeaderboardService
{
private const CACHE_TTL_SECONDS = 3600;
private const CREATOR_STORE_LIMIT = 10000;
private const ENTITY_STORE_LIMIT = 500;
public function calculateCreatorLeaderboard(string $period): int
{
$normalizedPeriod = $this->normalizePeriod($period);
$rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME
? $this->allTimeCreatorRows()
: $this->windowedCreatorRows($this->periodStart($normalizedPeriod));
return $this->persistRows(Leaderboard::TYPE_CREATOR, $normalizedPeriod, $rows, self::CREATOR_STORE_LIMIT);
}
public function calculateArtworkLeaderboard(string $period): int
{
$normalizedPeriod = $this->normalizePeriod($period);
$rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME
? $this->allTimeArtworkRows()
: $this->windowedArtworkRows($this->periodStart($normalizedPeriod));
return $this->persistRows(Leaderboard::TYPE_ARTWORK, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT);
}
public function calculateStoryLeaderboard(string $period): int
{
$normalizedPeriod = $this->normalizePeriod($period);
$rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME
? $this->allTimeStoryRows()
: $this->windowedStoryRows($this->periodStart($normalizedPeriod));
return $this->persistRows(Leaderboard::TYPE_STORY, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT);
}
public function refreshAll(): array
{
$results = [];
foreach ([
Leaderboard::TYPE_CREATOR,
Leaderboard::TYPE_ARTWORK,
Leaderboard::TYPE_STORY,
] as $type) {
foreach ($this->periods() as $period) {
$results[$type][$period] = match ($type) {
Leaderboard::TYPE_CREATOR => $this->calculateCreatorLeaderboard($period),
Leaderboard::TYPE_ARTWORK => $this->calculateArtworkLeaderboard($period),
Leaderboard::TYPE_STORY => $this->calculateStoryLeaderboard($period),
};
}
}
return $results;
}
public function getLeaderboard(string $type, string $period, int $limit = 50): array
{
$normalizedType = $this->normalizeType($type);
$normalizedPeriod = $this->normalizePeriod($period);
$limit = max(1, min($limit, 100));
return Cache::remember(
$this->cacheKey($normalizedType, $normalizedPeriod, $limit),
self::CACHE_TTL_SECONDS,
function () use ($normalizedType, $normalizedPeriod, $limit): array {
$items = Leaderboard::query()
->where('type', $normalizedType)
->where('period', $normalizedPeriod)
->orderByDesc('score')
->orderBy('entity_id')
->limit($limit)
->get(['entity_id', 'score'])
->values();
if ($items->isEmpty()) {
return [
'type' => $normalizedType,
'period' => $normalizedPeriod,
'items' => [],
];
}
$entities = match ($normalizedType) {
Leaderboard::TYPE_CREATOR => $this->creatorEntities($items->pluck('entity_id')->all()),
Leaderboard::TYPE_ARTWORK => $this->artworkEntities($items->pluck('entity_id')->all()),
Leaderboard::TYPE_STORY => $this->storyEntities($items->pluck('entity_id')->all()),
};
return [
'type' => $normalizedType,
'period' => $normalizedPeriod,
'items' => $items->values()->map(function (Leaderboard $row, int $index) use ($entities): array {
return [
'rank' => $index + 1,
'score' => round((float) $row->score, 1),
'entity' => $entities[(int) $row->entity_id] ?? null,
];
})->filter(fn (array $item): bool => $item['entity'] !== null)->values()->all(),
];
}
);
}
public function creatorRankSummary(int $userId, string $period = Leaderboard::PERIOD_WEEKLY): ?array
{
$normalizedPeriod = $this->normalizePeriod($period);
return Cache::remember(
sprintf('leaderboard:creator-rank:%d:%s', $userId, $normalizedPeriod),
self::CACHE_TTL_SECONDS,
function () use ($userId, $normalizedPeriod): ?array {
$row = Leaderboard::query()
->where('type', Leaderboard::TYPE_CREATOR)
->where('period', $normalizedPeriod)
->where('entity_id', $userId)
->first(['entity_id', 'score']);
if (! $row) {
return null;
}
$higherScores = Leaderboard::query()
->where('type', Leaderboard::TYPE_CREATOR)
->where('period', $normalizedPeriod)
->where(function ($query) use ($row): void {
$query->where('score', '>', $row->score)
->orWhere(function ($ties) use ($row): void {
$ties->where('score', '=', $row->score)
->where('entity_id', '<', $row->entity_id);
});
})
->count();
return [
'period' => $normalizedPeriod,
'rank' => $higherScores + 1,
'score' => round((float) $row->score, 1),
];
}
);
}
public function periods(): array
{
return [
Leaderboard::PERIOD_DAILY,
Leaderboard::PERIOD_WEEKLY,
Leaderboard::PERIOD_MONTHLY,
Leaderboard::PERIOD_ALL_TIME,
];
}
public function normalizePeriod(string $period): string
{
return match (strtolower(trim($period))) {
'daily' => Leaderboard::PERIOD_DAILY,
'weekly' => Leaderboard::PERIOD_WEEKLY,
'monthly' => Leaderboard::PERIOD_MONTHLY,
'all', 'all_time', 'all-time' => Leaderboard::PERIOD_ALL_TIME,
default => Leaderboard::PERIOD_WEEKLY,
};
}
private function normalizeType(string $type): string
{
return match (strtolower(trim($type))) {
'creator', 'creators' => Leaderboard::TYPE_CREATOR,
'artwork', 'artworks' => Leaderboard::TYPE_ARTWORK,
'story', 'stories' => Leaderboard::TYPE_STORY,
default => Leaderboard::TYPE_CREATOR,
};
}
private function periodStart(string $period): CarbonImmutable
{
$now = CarbonImmutable::now();
return match ($period) {
Leaderboard::PERIOD_DAILY => $now->subDay(),
Leaderboard::PERIOD_WEEKLY => $now->subWeek(),
Leaderboard::PERIOD_MONTHLY => $now->subMonth(),
default => $now->subWeek(),
};
}
private function persistRows(string $type, string $period, Collection $rows, int $limit): int
{
$trimmed = $rows
->sortByDesc('score')
->take($limit)
->values();
DB::transaction(function () use ($type, $period, $trimmed): void {
Leaderboard::query()
->where('type', $type)
->where('period', $period)
->delete();
if ($trimmed->isNotEmpty()) {
$timestamp = now();
Leaderboard::query()->insert(
$trimmed->map(fn (array $row): array => [
'type' => $type,
'period' => $period,
'entity_id' => (int) $row['entity_id'],
'score' => round((float) $row['score'], 2),
'created_at' => $timestamp,
'updated_at' => $timestamp,
])->all()
);
}
});
$this->flushCache($type, $period);
return $trimmed->count();
}
private function flushCache(string $type, string $period): void
{
foreach ([10, 25, 50, 100] as $limit) {
Cache::forget($this->cacheKey($type, $period, $limit));
}
if ($type === Leaderboard::TYPE_CREATOR) {
Cache::forget('leaderboard:top-creators-widget:' . $period);
}
}
private function cacheKey(string $type, string $period, int $limit): string
{
return sprintf('leaderboard:%s:%s:%d', $type, $period, $limit);
}
private function allTimeCreatorRows(): Collection
{
return User::query()
->from('users')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'users.id')
->whereNull('users.deleted_at')
->where('users.is_active', true)
->select([
'users.id',
DB::raw('COALESCE(users.xp, 0) as xp'),
DB::raw('COALESCE(us.followers_count, 0) as followers_count'),
DB::raw('COALESCE(us.favorites_received_count, 0) as likes_received'),
DB::raw('COALESCE(us.artwork_views_received_count, 0) as artwork_views'),
])
->get()
->map(function ($row): array {
$score = ((int) $row->xp * 1)
+ ((int) $row->followers_count * 10)
+ ((int) $row->likes_received * 2)
+ ((int) $row->artwork_views * 0.1);
return [
'entity_id' => (int) $row->id,
'score' => $score,
];
})
->filter(fn (array $row): bool => $row['score'] > 0)
->values();
}
private function windowedCreatorRows(CarbonImmutable $start): Collection
{
$xp = DB::table('user_xp_logs')
->select('user_id', DB::raw('SUM(xp) as xp'))
->where('created_at', '>=', $start)
->groupBy('user_id');
$followers = DB::table('user_followers')
->select('user_id', DB::raw('COUNT(*) as followers_count'))
->where('created_at', '>=', $start)
->groupBy('user_id');
$likes = DB::table('artwork_likes as likes')
->join('artworks as artworks', 'artworks.id', '=', 'likes.artwork_id')
->select('artworks.user_id', DB::raw('COUNT(*) as likes_received'))
->where('likes.created_at', '>=', $start)
->groupBy('artworks.user_id');
$views = DB::query()
->fromSub($this->artworkSnapshotDeltas($start), 'deltas')
->join('artworks as artworks', 'artworks.id', '=', 'deltas.artwork_id')
->select('artworks.user_id', DB::raw('SUM(deltas.views_delta) as artwork_views'))
->groupBy('artworks.user_id');
return User::query()
->from('users')
->leftJoinSub($xp, 'xp', 'xp.user_id', '=', 'users.id')
->leftJoinSub($followers, 'followers', 'followers.user_id', '=', 'users.id')
->leftJoinSub($likes, 'likes', 'likes.user_id', '=', 'users.id')
->leftJoinSub($views, 'views', 'views.user_id', '=', 'users.id')
->whereNull('users.deleted_at')
->where('users.is_active', true)
->select([
'users.id',
DB::raw('COALESCE(xp.xp, 0) as xp'),
DB::raw('COALESCE(followers.followers_count, 0) as followers_count'),
DB::raw('COALESCE(likes.likes_received, 0) as likes_received'),
DB::raw('COALESCE(views.artwork_views, 0) as artwork_views'),
])
->get()
->map(function ($row): array {
$score = ((int) $row->xp * 1)
+ ((int) $row->followers_count * 10)
+ ((int) $row->likes_received * 2)
+ ((float) $row->artwork_views * 0.1);
return [
'entity_id' => (int) $row->id,
'score' => $score,
];
})
->filter(fn (array $row): bool => $row['score'] > 0)
->values();
}
private function allTimeArtworkRows(): Collection
{
return Artwork::query()
->from('artworks')
->join('artwork_stats as stats', 'stats.artwork_id', '=', 'artworks.id')
->public()
->select([
'artworks.id',
DB::raw('COALESCE(stats.favorites, 0) as likes_count'),
DB::raw('COALESCE(stats.views, 0) as views_count'),
DB::raw('COALESCE(stats.downloads, 0) as downloads_count'),
DB::raw('COALESCE(stats.comments_count, 0) as comments_count'),
])
->get()
->map(fn ($row): array => [
'entity_id' => (int) $row->id,
'score' => ((int) $row->likes_count * 3)
+ ((int) $row->views_count * 1)
+ ((int) $row->downloads_count * 5)
+ ((int) $row->comments_count * 4),
])
->filter(fn (array $row): bool => $row['score'] > 0)
->values();
}
private function windowedArtworkRows(CarbonImmutable $start): Collection
{
$views = $this->artworkSnapshotDeltas($start);
$likes = DB::table('artwork_likes')
->select('artwork_id', DB::raw('COUNT(*) as favourites_delta'))
->where('created_at', '>=', $start)
->groupBy('artwork_id');
$downloads = DB::table('artwork_downloads')
->select('artwork_id', DB::raw('COUNT(*) as downloads_delta'))
->where('created_at', '>=', $start)
->groupBy('artwork_id');
$comments = DB::table('artwork_comments')
->select('artwork_id', DB::raw('COUNT(*) as comments_delta'))
->where('created_at', '>=', $start)
->where('is_approved', true)
->whereNull('deleted_at')
->groupBy('artwork_id');
return Artwork::query()
->from('artworks')
->leftJoinSub($views, 'views', 'views.artwork_id', '=', 'artworks.id')
->leftJoinSub($likes, 'likes', 'likes.artwork_id', '=', 'artworks.id')
->leftJoinSub($downloads, 'downloads', 'downloads.artwork_id', '=', 'artworks.id')
->leftJoinSub($comments, 'comments', 'comments.artwork_id', '=', 'artworks.id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNull('artworks.deleted_at')
->whereNotNull('artworks.published_at')
->select([
'artworks.id',
DB::raw('COALESCE(likes.favourites_delta, 0) as favourites_delta'),
DB::raw('COALESCE(views.views_delta, 0) as views_delta'),
DB::raw('COALESCE(downloads.downloads_delta, 0) as downloads_delta'),
DB::raw('COALESCE(comments.comments_delta, 0) as comments_delta'),
])
->get()
->map(fn ($row): array => [
'entity_id' => (int) $row->id,
'score' => ((int) $row->favourites_delta * 3)
+ ((int) $row->views_delta * 1)
+ ((int) $row->downloads_delta * 5)
+ ((int) $row->comments_delta * 4),
])
->filter(fn (array $row): bool => $row['score'] > 0)
->values();
}
private function allTimeStoryRows(): Collection
{
return Story::query()
->published()
->select(['id', 'views', 'likes_count', 'comments_count', 'reading_time'])
->get()
->map(fn (Story $story): array => [
'entity_id' => (int) $story->id,
'score' => ((int) $story->views * 1)
+ ((int) $story->likes_count * 3)
+ ((int) $story->comments_count * 4)
+ ((int) $story->reading_time * 0.5),
])
->filter(fn (array $row): bool => $row['score'] > 0)
->values();
}
private function windowedStoryRows(CarbonImmutable $start): Collection
{
$views = StoryView::query()
->select('story_id', DB::raw('COUNT(*) as views_count'))
->where('created_at', '>=', $start)
->groupBy('story_id');
$likes = StoryLike::query()
->select('story_id', DB::raw('COUNT(*) as likes_count'))
->where('created_at', '>=', $start)
->groupBy('story_id');
return Story::query()
->from('stories')
->leftJoinSub($views, 'views', 'views.story_id', '=', 'stories.id')
->leftJoinSub($likes, 'likes', 'likes.story_id', '=', 'stories.id')
->published()
->select([
'stories.id',
'stories.comments_count',
'stories.reading_time',
DB::raw('COALESCE(views.views_count, 0) as views_count'),
DB::raw('COALESCE(likes.likes_count, 0) as likes_count'),
])
->get()
->map(fn ($row): array => [
'entity_id' => (int) $row->id,
'score' => ((int) $row->views_count * 1)
+ ((int) $row->likes_count * 3)
+ ((int) $row->comments_count * 4)
+ ((int) $row->reading_time * 0.5),
])
->filter(fn (array $row): bool => $row['score'] > 0)
->values();
}
private function artworkSnapshotDeltas(CarbonImmutable $start): \Illuminate\Database\Query\Builder
{
return ArtworkMetricSnapshotHourly::query()
->from('artwork_metric_snapshots_hourly as snapshots')
->where('snapshots.bucket_hour', '>=', $start)
->select([
'snapshots.artwork_id',
DB::raw('GREATEST(MAX(snapshots.views_count) - MIN(snapshots.views_count), 0) as views_delta'),
DB::raw('GREATEST(MAX(snapshots.downloads_count) - MIN(snapshots.downloads_count), 0) as downloads_delta'),
DB::raw('GREATEST(MAX(snapshots.favourites_count) - MIN(snapshots.favourites_count), 0) as favourites_delta'),
DB::raw('GREATEST(MAX(snapshots.comments_count) - MIN(snapshots.comments_count), 0) as comments_delta'),
])
->groupBy('snapshots.artwork_id')
->toBase();
}
private function creatorEntities(array $ids): array
{
return User::query()
->from('users')
->leftJoin('user_profiles as profiles', 'profiles.user_id', '=', 'users.id')
->whereIn('users.id', $ids)
->select([
'users.id',
'users.username',
'users.name',
'users.level',
'users.rank',
'profiles.avatar_hash',
])
->get()
->mapWithKeys(fn ($row): array => [
(int) $row->id => [
'id' => (int) $row->id,
'type' => Leaderboard::TYPE_CREATOR,
'name' => (string) ($row->username ?: $row->name ?: 'Creator'),
'username' => $row->username,
'url' => $row->username ? '/@' . $row->username : null,
'avatar' => \App\Support\AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 128),
'level' => (int) ($row->level ?? 1),
'rank' => (string) ($row->rank ?? 'Newbie'),
],
])
->all();
}
private function artworkEntities(array $ids): array
{
return Artwork::query()
->with(['user.profile'])
->whereIn('id', $ids)
->get()
->mapWithKeys(fn (Artwork $artwork): array => [
(int) $artwork->id => [
'id' => (int) $artwork->id,
'type' => Leaderboard::TYPE_ARTWORK,
'name' => $artwork->title,
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
'image' => $artwork->thumbUrl('md') ?? $artwork->thumbnail_url,
'creator_name' => (string) ($artwork->user?->username ?: $artwork->user?->name ?: 'Creator'),
'creator_url' => $artwork->user?->username ? '/@' . $artwork->user->username : null,
],
])
->all();
}
private function storyEntities(array $ids): array
{
return Story::query()
->with('creator.profile')
->whereIn('id', $ids)
->get()
->mapWithKeys(fn (Story $story): array => [
(int) $story->id => [
'id' => (int) $story->id,
'type' => Leaderboard::TYPE_STORY,
'name' => $story->title,
'url' => '/stories/' . $story->slug,
'image' => $story->cover_url,
'creator_name' => (string) ($story->creator?->username ?: $story->creator?->name ?: 'Creator'),
'creator_url' => $story->creator?->username ? '/@' . $story->creator->username : null,
],
])
->all();
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Notification;
use App\Models\User;
use App\Support\AvatarUrl;
use Illuminate\Support\Collection;
final class NotificationService
{
public function listForUser(User $user, int $page = 1, int $perPage = 20): array
{
$resolvedPage = max(1, $page);
$resolvedPerPage = max(1, min(50, $perPage));
$notifications = $user->notifications()
->latest()
->paginate($resolvedPerPage, ['*'], 'page', $resolvedPage);
$actorIds = collect($notifications->items())
->map(function (Notification $notification): ?int {
$data = is_array($notification->data) ? $notification->data : [];
return isset($data['actor_id']) ? (int) $data['actor_id'] : null;
})
->filter()
->unique()
->values();
$actors = $actorIds->isEmpty()
? collect()
: User::query()
->with('profile:user_id,avatar_hash')
->whereIn('id', $actorIds->all())
->get()
->keyBy('id');
return [
'data' => collect($notifications->items())
->map(fn (Notification $notification) => $this->mapNotification($notification, $actors))
->values()
->all(),
'unread_count' => $user->unreadNotifications()->count(),
'meta' => [
'total' => $notifications->total(),
'current_page' => $notifications->currentPage(),
'last_page' => $notifications->lastPage(),
'per_page' => $notifications->perPage(),
],
];
}
public function markAllRead(User $user): void
{
$user->unreadNotifications()->update(['read_at' => now()]);
}
public function markRead(User $user, string $id): void
{
$notification = $user->notifications()->findOrFail($id);
$notification->markAsRead();
}
private function mapNotification(Notification $notification, Collection $actors): array
{
$data = is_array($notification->data) ? $notification->data : [];
$actorId = isset($data['actor_id']) ? (int) $data['actor_id'] : null;
$actor = $actorId ? $actors->get($actorId) : null;
return [
'id' => (string) $notification->id,
'type' => (string) ($data['type'] ?? $notification->type ?? 'notification'),
'message' => (string) ($data['message'] ?? 'New activity'),
'url' => $data['url'] ?? null,
'created_at' => $notification->created_at?->toIso8601String(),
'time_ago' => $notification->created_at?->diffForHumans(),
'read' => $notification->read_at !== null,
'actor' => $actor ? [
'id' => (int) $actor->id,
'name' => $actor->name,
'username' => $actor->username,
'avatar_url' => AvatarUrl::forUser((int) $actor->id, $actor->profile?->avatar_hash, 64),
'profile_url' => $actor->username ? '/@' . $actor->username : null,
] : null,
];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Services\Posts; namespace App\Services\Posts;
use App\Models\Achievement;
use App\Models\Post; use App\Models\Post;
use App\Models\PostTarget; use App\Models\PostTarget;
use App\Models\User; use App\Models\User;
@@ -67,6 +68,16 @@ class PostAchievementService
], $artworkId); ], $artworkId);
} }
public function achievementUnlocked(User $user, Achievement $achievement): void
{
$this->createAchievementPost($user, 'unlock_' . $achievement->slug, [
'achievement_id' => $achievement->id,
'achievement_name' => $achievement->name,
'message' => '🎉 Unlocked achievement: ' . $achievement->name,
'xp_reward' => (int) $achievement->xp_reward,
]);
}
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
private function createAchievementPost( private function createAchievementPost(

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\ArtworkComment;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
final class ReceivedCommentsInboxService
{
public function queryForUser(User $user): Builder
{
return ArtworkComment::query()
->whereHas('artwork', function ($query) use ($user): void {
$query->where('user_id', $user->id)
->where('is_approved', true)
->whereNull('deleted_at');
})
->where('user_id', '!=', $user->id)
->where('is_approved', true)
->whereNull('deleted_at');
}
public function unreadCountForUser(User $user): int
{
return (int) $this->unreadQueryForUser($user)->count();
}
public function markInboxRead(User $user): void
{
$readAt = Carbon::now();
$this->unreadQueryForUser($user)
->select('artwork_comments.id')
->orderBy('artwork_comments.id')
->chunkById(200, function ($comments) use ($user, $readAt): void {
$rows = collect($comments)->map(function ($comment) use ($user, $readAt): array {
return [
'user_id' => $user->id,
'artwork_comment_id' => (int) $comment->id,
'read_at' => $readAt,
'created_at' => $readAt,
'updated_at' => $readAt,
];
})->all();
if ($rows !== []) {
DB::table('user_received_comment_reads')->insertOrIgnore($rows);
}
}, 'artwork_comments.id', 'id');
}
private function unreadQueryForUser(User $user): Builder
{
return $this->queryForUser($user)
->whereNotExists(function ($query) use ($user): void {
$query->selectRaw('1')
->from('user_received_comment_reads as ucr')
->whereColumn('ucr.artwork_comment_id', 'artwork_comments.id')
->where('ucr.user_id', $user->id);
});
}
}

View File

@@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Events\Achievements\AchievementCheckRequested;
use App\Models\Story;
use App\Models\StoryBookmark;
use App\Models\StoryComment;
use App\Models\StoryLike;
use App\Models\User;
use App\Notifications\StoryCommentedNotification;
use App\Notifications\StoryLikedNotification;
use App\Notifications\StoryMentionedNotification;
use App\Services\ContentSanitizer;
use App\Support\AvatarUrl;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
final class SocialService
{
private const COMMENT_MAX_LENGTH = 10000;
public function __construct(
private readonly \App\Services\ActivityService $activity,
private readonly FollowService $followService,
private readonly XPService $xp,
) {}
public function toggleFollow(int $actorId, int $targetId, bool $state): array
{
if ($state) {
$this->followService->follow($actorId, $targetId);
} else {
$this->followService->unfollow($actorId, $targetId);
}
return [
'following' => $state,
'followers_count' => $this->followService->followersCount($targetId),
];
}
public function toggleStoryLike(User $actor, Story $story, bool $state): array
{
$changed = false;
if ($state) {
$like = StoryLike::query()->firstOrCreate([
'story_id' => (int) $story->id,
'user_id' => (int) $actor->id,
]);
$changed = $like->wasRecentlyCreated;
} else {
$changed = StoryLike::query()
->where('story_id', $story->id)
->where('user_id', $actor->id)
->delete() > 0;
}
$likesCount = StoryLike::query()->where('story_id', $story->id)->count();
$story->forceFill(['likes_count' => $likesCount])->save();
if ($state && $changed) {
$this->activity->record((int) $actor->id, 'story_like', 'story', (int) $story->id);
if ((int) $story->creator_id > 0 && (int) $story->creator_id !== (int) $actor->id) {
$creator = User::query()->find($story->creator_id);
if ($creator) {
$creator->notify(new StoryLikedNotification($story, $actor));
event(new AchievementCheckRequested((int) $creator->id));
}
}
}
return [
'ok' => true,
'liked' => StoryLike::query()->where('story_id', $story->id)->where('user_id', $actor->id)->exists(),
'likes_count' => $likesCount,
];
}
public function toggleStoryBookmark(User $actor, Story $story, bool $state): array
{
if ($state) {
StoryBookmark::query()->firstOrCreate([
'story_id' => (int) $story->id,
'user_id' => (int) $actor->id,
]);
} else {
StoryBookmark::query()
->where('story_id', $story->id)
->where('user_id', $actor->id)
->delete();
}
return [
'ok' => true,
'bookmarked' => StoryBookmark::query()->where('story_id', $story->id)->where('user_id', $actor->id)->exists(),
'bookmarks_count' => StoryBookmark::query()->where('story_id', $story->id)->count(),
];
}
public function listStoryComments(Story $story, ?int $viewerId, int $page = 1, int $perPage = 20): array
{
$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')
->paginate($perPage, ['*'], 'page', max(1, $page));
return [
'data' => $comments->getCollection()->map(fn (StoryComment $comment) => $this->formatComment($comment, $viewerId, true))->values()->all(),
'meta' => [
'current_page' => $comments->currentPage(),
'last_page' => $comments->lastPage(),
'total' => $comments->total(),
'per_page' => $comments->perPage(),
],
];
}
public function addStoryComment(User $actor, Story $story, string $raw, ?int $parentId = null): StoryComment
{
$trimmed = trim($raw);
if ($trimmed === '' || mb_strlen($trimmed) > self::COMMENT_MAX_LENGTH) {
abort(422, 'Invalid comment content.');
}
$errors = ContentSanitizer::validate($trimmed);
if ($errors) {
abort(422, implode(' ', $errors));
}
$parent = null;
if ($parentId !== null) {
$parent = StoryComment::query()
->where('story_id', $story->id)
->where('id', $parentId)
->where('is_approved', true)
->whereNull('deleted_at')
->first();
if (! $parent) {
abort(422, 'The comment you are replying to is no longer available.');
}
}
$comment = DB::transaction(function () use ($actor, $story, $trimmed, $parent): StoryComment {
$comment = StoryComment::query()->create([
'story_id' => (int) $story->id,
'user_id' => (int) $actor->id,
'parent_id' => $parent?->id,
'content' => $trimmed,
'raw_content' => $trimmed,
'rendered_content' => ContentSanitizer::render($trimmed),
'is_approved' => true,
]);
$commentsCount = StoryComment::query()
->where('story_id', $story->id)
->whereNull('deleted_at')
->count();
$story->forceFill(['comments_count' => $commentsCount])->save();
return $comment;
});
$comment->load(['user.profile', 'approvedReplies']);
$this->activity->record((int) $actor->id, 'story_comment', 'story', (int) $story->id, ['comment_id' => (int) $comment->id]);
$this->xp->awardCommentCreated((int) $actor->id, (int) $comment->id, 'story');
$this->notifyStoryCommentRecipients($story, $comment, $actor, $parent);
return $comment;
}
public function deleteStoryComment(User $actor, StoryComment $comment): void
{
$story = $comment->story;
$canDelete = (int) $comment->user_id === (int) $actor->id
|| (int) ($story?->creator_id ?? 0) === (int) $actor->id
|| $actor->hasRole('admin')
|| $actor->hasRole('moderator');
abort_unless($canDelete, 403);
$comment->delete();
if ($story) {
$commentsCount = StoryComment::query()
->where('story_id', $story->id)
->whereNull('deleted_at')
->count();
$story->forceFill(['comments_count' => $commentsCount])->save();
}
}
public function storyStateFor(?User $viewer, Story $story): array
{
if (! $viewer) {
return [
'liked' => false,
'bookmarked' => false,
'is_following_creator' => false,
'likes_count' => (int) $story->likes_count,
'comments_count' => (int) $story->comments_count,
'bookmarks_count' => StoryBookmark::query()->where('story_id', $story->id)->count(),
];
}
return [
'liked' => StoryLike::query()->where('story_id', $story->id)->where('user_id', $viewer->id)->exists(),
'bookmarked' => StoryBookmark::query()->where('story_id', $story->id)->where('user_id', $viewer->id)->exists(),
'is_following_creator' => $story->creator_id ? $this->followService->isFollowing((int) $viewer->id, (int) $story->creator_id) : false,
'likes_count' => StoryLike::query()->where('story_id', $story->id)->count(),
'comments_count' => StoryComment::query()->where('story_id', $story->id)->whereNull('deleted_at')->count(),
'bookmarks_count' => StoryBookmark::query()->where('story_id', $story->id)->count(),
];
}
public function formatComment(StoryComment $comment, ?int $viewerId, bool $includeReplies = false): array
{
$user = $comment->user;
$avatarHash = $user?->profile?->avatar_hash;
return [
'id' => (int) $comment->id,
'parent_id' => $comment->parent_id,
'raw_content' => $comment->raw_content ?? $comment->content,
'rendered_content' => $comment->rendered_content,
'created_at' => $comment->created_at?->toIso8601String(),
'time_ago' => $comment->created_at ? Carbon::parse($comment->created_at)->diffForHumans() : null,
'can_delete' => $viewerId !== null && ((int) $comment->user_id === $viewerId || (int) ($comment->story?->creator_id ?? 0) === $viewerId),
'user' => [
'id' => (int) ($user?->id ?? 0),
'username' => $user?->username,
'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@' . $user->username : null,
'avatar_url' => AvatarUrl::forUser((int) ($user?->id ?? 0), $avatarHash, 64),
'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'),
],
'replies' => $includeReplies && $comment->relationLoaded('approvedReplies')
? $comment->approvedReplies->map(fn (StoryComment $reply) => $this->formatComment($reply, $viewerId, true))->values()->all()
: [],
];
}
private function notifyStoryCommentRecipients(Story $story, StoryComment $comment, User $actor, ?StoryComment $parent): void
{
$notifiedUserIds = [];
if ((int) ($story->creator_id ?? 0) > 0 && (int) $story->creator_id !== (int) $actor->id) {
$creator = User::query()->find($story->creator_id);
if ($creator) {
$creator->notify(new StoryCommentedNotification($story, $comment, $actor));
$notifiedUserIds[] = (int) $creator->id;
}
}
if ($parent && (int) $parent->user_id !== (int) $actor->id && ! in_array((int) $parent->user_id, $notifiedUserIds, true)) {
$parentUser = User::query()->find($parent->user_id);
if ($parentUser) {
$parentUser->notify(new StoryCommentedNotification($story, $comment, $actor));
$notifiedUserIds[] = (int) $parentUser->id;
}
}
$mentionedUsers = User::query()
->whereIn(DB::raw('LOWER(username)'), $this->extractMentions((string) ($comment->raw_content ?? '')))
->get();
foreach ($mentionedUsers as $mentionedUser) {
if ((int) $mentionedUser->id === (int) $actor->id || in_array((int) $mentionedUser->id, $notifiedUserIds, true)) {
continue;
}
$mentionedUser->notify(new StoryMentionedNotification($story, $comment, $actor));
}
}
private function extractMentions(string $content): array
{
preg_match_all('/(^|[^A-Za-z0-9_])@([A-Za-z0-9_-]{3,20})/', $content, $matches);
return collect($matches[2] ?? [])
->map(fn ($username) => strtolower((string) $username))
->unique()
->values()
->all();
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Events\Achievements\AchievementCheckRequested;
use App\Models\ActivityEvent;
use App\Models\Story;
use App\Notifications\StoryStatusNotification;
final class StoryPublicationService
{
public function __construct(
private readonly XPService $xp,
private readonly ActivityService $activity,
) {
}
public function publish(Story $story, string $notificationEvent = 'published', array $attributes = []): Story
{
$wasPublished = $this->isPublished($story);
$story->fill(array_merge([
'status' => 'published',
'published_at' => $story->published_at ?? now(),
'scheduled_for' => null,
], $attributes));
if ($story->isDirty()) {
$story->save();
}
$this->afterPersistence($story, $notificationEvent, $wasPublished);
return $story;
}
public function afterPersistence(Story $story, string $notificationEvent = 'published', bool $wasPublished = false): void
{
if (! $this->isPublished($story)) {
return;
}
if (! $wasPublished && $story->creator_id !== null) {
$this->xp->awardStoryPublished((int) $story->creator_id, (int) $story->id);
event(new AchievementCheckRequested((int) $story->creator_id));
try {
$this->activity->record(
actorId: (int) $story->creator_id,
type: ActivityEvent::TYPE_UPLOAD,
targetType: ActivityEvent::TARGET_STORY,
targetId: (int) $story->id,
meta: [
'story_slug' => (string) $story->slug,
'story_title' => (string) $story->title,
],
);
} catch (\Throwable) {
// Activity logging should not block publication.
}
}
$story->creator?->notify(new StoryStatusNotification($story, $notificationEvent));
}
private function isPublished(Story $story): bool
{
return $story->published_at !== null || $story->status === 'published';
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Services;
use App\Jobs\IndexUserJob; use App\Jobs\IndexUserJob;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
/** /**
* UserStatsService single source of truth for user_statistics counters. * UserStatsService single source of truth for user_statistics counters.
@@ -253,7 +254,7 @@ final class UserStatsService
DB::table('user_statistics') DB::table('user_statistics')
->where('user_id', $userId) ->where('user_id', $userId)
->update([ ->update([
$column => DB::raw("GREATEST(0, COALESCE({$column}, 0) + {$by})"), $column => $this->nonNegativeCounterExpression($column, $by),
'updated_at' => now(), 'updated_at' => now(),
]); ]);
} }
@@ -264,7 +265,7 @@ final class UserStatsService
->where('user_id', $userId) ->where('user_id', $userId)
->where($column, '>', 0) ->where($column, '>', 0)
->update([ ->update([
$column => DB::raw("GREATEST(0, COALESCE({$column}, 0) - {$by})"), $column => $this->nonNegativeCounterExpression($column, -$by),
'updated_at' => now(), 'updated_at' => now(),
]); ]);
} }
@@ -279,6 +280,22 @@ final class UserStatsService
]); ]);
} }
private function nonNegativeCounterExpression(string $column, int $delta)
{
if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $column)) {
throw new InvalidArgumentException('Invalid statistics column name.');
}
$driver = DB::connection()->getDriverName();
$deltaSql = $delta >= 0 ? "+ {$delta}" : "- ".abs($delta);
if ($driver === 'sqlite') {
return DB::raw("max(0, COALESCE({$column}, 0) {$deltaSql})");
}
return DB::raw("GREATEST(0, COALESCE({$column}, 0) {$deltaSql})");
}
/** /**
* Queue a Meilisearch reindex for the user. * Queue a Meilisearch reindex for the user.
* Uses IndexUserJob to avoid blocking the request. * Uses IndexUserJob to avoid blocking the request.

292
app/Services/XPService.php Normal file
View File

@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Events\Achievements\UserXpUpdated;
use App\Models\User;
use App\Models\UserXpLog;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class XPService
{
private const LEVEL_THRESHOLDS = [
1 => 0,
2 => 100,
3 => 300,
4 => 800,
5 => 2000,
6 => 5000,
7 => 12000,
];
private const RANKS = [
1 => 'Newbie',
2 => 'Explorer',
3 => 'Contributor',
4 => 'Creator',
5 => 'Pro Creator',
6 => 'Elite',
7 => 'Legend',
];
private const DAILY_CAPS = [
'artwork_view_received' => 200,
'comment_created' => 100,
'story_published' => 200,
'artwork_published' => 250,
'follower_received' => 400,
'artwork_like_received' => 500,
];
public function addXP(
User|int $user,
int $amount,
string $action,
?int $referenceId = null,
bool $dispatchEvent = true,
): bool
{
if ($amount <= 0) {
return false;
}
$userId = $user instanceof User ? (int) $user->id : $user;
if ($userId <= 0) {
return false;
}
$baseAction = $this->baseAction($action);
$awardAmount = $this->applyDailyCap($userId, $amount, $baseAction);
if ($awardAmount <= 0) {
return false;
}
DB::transaction(function () use ($userId, $awardAmount, $action, $referenceId): void {
/** @var User $lockedUser */
$lockedUser = User::query()->lockForUpdate()->findOrFail($userId);
$nextXp = max(0, (int) $lockedUser->xp + $awardAmount);
$level = $this->calculateLevel($nextXp);
$rank = $this->getRank($level);
$lockedUser->forceFill([
'xp' => $nextXp,
'level' => $level,
'rank' => $rank,
])->save();
UserXpLog::query()->create([
'user_id' => $userId,
'action' => $action,
'xp' => $awardAmount,
'reference_id' => $referenceId,
'created_at' => now(),
]);
});
$this->forgetSummaryCache($userId);
if ($dispatchEvent) {
event(new UserXpUpdated($userId));
}
return true;
}
public function awardArtworkPublished(int $userId, int $artworkId): bool
{
return $this->awardUnique($userId, 50, 'artwork_published', $artworkId);
}
public function awardArtworkLikeReceived(int $userId, int $artworkId, int $actorId): bool
{
return $this->awardUnique($userId, 5, 'artwork_like_received', $artworkId, $actorId);
}
public function awardFollowerReceived(int $userId, int $followerId): bool
{
return $this->awardUnique($userId, 20, 'follower_received', $followerId, $followerId);
}
public function awardStoryPublished(int $userId, int $storyId): bool
{
return $this->awardUnique($userId, 40, 'story_published', $storyId);
}
public function awardCommentCreated(int $userId, int $referenceId, string $scope = 'generic'): bool
{
return $this->awardUnique($userId, 5, 'comment_created:' . $scope, $referenceId);
}
public function awardArtworkViewReceived(int $userId, int $artworkId, ?int $viewerId = null, ?string $ipAddress = null): bool
{
$viewerKey = $viewerId !== null && $viewerId > 0
? 'user:' . $viewerId
: 'guest:' . sha1((string) ($ipAddress ?: 'guest'));
$expiresAt = now()->endOfDay();
$qualifierKey = sprintf('xp:view:qualifier:%d:%d:%s:%s', $userId, $artworkId, $viewerKey, now()->format('Ymd'));
if (! Cache::add($qualifierKey, true, $expiresAt)) {
return false;
}
$bucketKey = sprintf('xp:view:bucket:%d:%s', $userId, now()->format('Ymd'));
Cache::add($bucketKey, 0, $expiresAt);
$bucketCount = Cache::increment($bucketKey);
if ($bucketCount % 10 !== 0) {
return false;
}
return $this->addXP($userId, 1, 'artwork_view_received', $artworkId);
}
public function calculateLevel(int $xp): int
{
$resolvedLevel = 1;
foreach (self::LEVEL_THRESHOLDS as $level => $threshold) {
if ($xp >= $threshold) {
$resolvedLevel = $level;
}
}
return $resolvedLevel;
}
public function getRank(int $level): string
{
return self::RANKS[$level] ?? Arr::last(self::RANKS);
}
public function summary(User|int $user): array
{
$userId = $user instanceof User ? (int) $user->id : $user;
return Cache::remember(
$this->summaryCacheKey($userId),
now()->addMinutes(10),
function () use ($userId): array {
$currentUser = User::query()->findOrFail($userId, ['id', 'xp', 'level', 'rank']);
$currentLevel = max(1, (int) $currentUser->level);
$currentXp = max(0, (int) $currentUser->xp);
$currentThreshold = self::LEVEL_THRESHOLDS[$currentLevel] ?? 0;
$nextLevel = min($currentLevel + 1, array_key_last(self::LEVEL_THRESHOLDS));
$nextLevelXp = self::LEVEL_THRESHOLDS[$nextLevel] ?? $currentXp;
$range = max(1, $nextLevelXp - $currentThreshold);
$progressWithinLevel = min($range, max(0, $currentXp - $currentThreshold));
$progressPercent = $currentLevel >= array_key_last(self::LEVEL_THRESHOLDS)
? 100
: (int) round(($progressWithinLevel / $range) * 100);
return [
'xp' => $currentXp,
'level' => $currentLevel,
'rank' => (string) ($currentUser->rank ?: $this->getRank($currentLevel)),
'current_level_xp' => $currentThreshold,
'next_level_xp' => $nextLevelXp,
'progress_xp' => $progressWithinLevel,
'progress_percent' => $progressPercent,
'max_level' => $currentLevel >= array_key_last(self::LEVEL_THRESHOLDS),
];
}
);
}
public function recalculateStoredProgress(User|int $user, bool $write = true): array
{
$userId = $user instanceof User ? (int) $user->id : $user;
/** @var User $currentUser */
$currentUser = User::query()->findOrFail($userId, ['id', 'xp', 'level', 'rank']);
$computedXp = (int) UserXpLog::query()
->where('user_id', $userId)
->sum('xp');
$computedLevel = $this->calculateLevel($computedXp);
$computedRank = $this->getRank($computedLevel);
$changed = (int) $currentUser->xp !== $computedXp
|| (int) $currentUser->level !== $computedLevel
|| (string) $currentUser->rank !== $computedRank;
if ($write && $changed) {
$currentUser->forceFill([
'xp' => $computedXp,
'level' => $computedLevel,
'rank' => $computedRank,
])->save();
$this->forgetSummaryCache($userId);
}
return [
'user_id' => $userId,
'changed' => $changed,
'previous' => [
'xp' => (int) $currentUser->xp,
'level' => (int) $currentUser->level,
'rank' => (string) $currentUser->rank,
],
'computed' => [
'xp' => $computedXp,
'level' => $computedLevel,
'rank' => $computedRank,
],
];
}
private function awardUnique(int $userId, int $amount, string $action, int $referenceId, ?int $actorId = null): bool
{
$actionKey = $actorId !== null ? $action . ':' . $actorId : $action;
$alreadyAwarded = UserXpLog::query()
->where('user_id', $userId)
->where('action', $actionKey)
->where('reference_id', $referenceId)
->exists();
if ($alreadyAwarded) {
return false;
}
return $this->addXP($userId, $amount, $actionKey, $referenceId);
}
private function applyDailyCap(int $userId, int $amount, string $baseAction): int
{
$cap = self::DAILY_CAPS[$baseAction] ?? null;
if ($cap === null) {
return $amount;
}
$dayStart = Carbon::now()->startOfDay();
$awardedToday = (int) UserXpLog::query()
->where('user_id', $userId)
->where('action', 'like', $baseAction . '%')
->where('created_at', '>=', $dayStart)
->sum('xp');
return max(0, min($amount, $cap - $awardedToday));
}
private function baseAction(string $action): string
{
return explode(':', $action, 2)[0];
}
private function forgetSummaryCache(int $userId): void
{
Cache::forget($this->summaryCacheKey($userId));
}
private function summaryCacheKey(int $userId): string
{
return 'xp:summary:' . $userId;
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Support; namespace App\Support;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class AvatarUrl class AvatarUrl
{ {
@@ -22,16 +21,14 @@ class AvatarUrl
} }
$base = rtrim((string) config('cdn.avatar_url', 'https://files.skinbase.org'), '/'); $base = rtrim((string) config('cdn.avatar_url', 'https://files.skinbase.org'), '/');
$resolvedSize = self::resolveSize($size);
// Use hash-based path: avatars/ab/cd/{hash}/{size}.webp?v={hash} // Use hash-based path: avatars/ab/cd/{hash}/{size}.webp?v={hash}
$p1 = substr($avatarHash, 0, 2); $p1 = substr($avatarHash, 0, 2);
$p2 = substr($avatarHash, 2, 2); $p2 = substr($avatarHash, 2, 2);
$diskPath = sprintf('avatars/%s/%s/%s/%d.webp', $p1, $p2, $avatarHash, $size);
// Always use CDN-hosted avatar files. // Always use CDN-hosted avatar files.
//return sprintf('%s/avatars/%s/%s/%s/%d.webp?v=%s', $base, $p1, $p2, $avatarHash, $size, $avatarHash); return sprintf('%s/avatars/%s/%s/%s/%d.webp', $base, $p1, $p2, $avatarHash, $resolvedSize);
return sprintf('%s/avatars/%s/%s/%s/%d.webp', $base, $p1, $p2, $avatarHash, $size);
} }
public static function default(): string public static function default(): string
@@ -59,4 +56,26 @@ class AvatarUrl
return self::$hashCache[$userId]; return self::$hashCache[$userId];
} }
private static function resolveSize(int $requestedSize): int
{
$sizes = array_values(array_filter(
(array) config('avatars.sizes', [32, 64, 128, 256, 512]),
static fn ($size): bool => (int) $size > 0
));
if ($sizes === []) {
return max(1, $requestedSize);
}
sort($sizes);
foreach ($sizes as $size) {
if ($requestedSize <= (int) $size) {
return (int) $size;
}
}
return (int) end($sizes);
}
} }

View File

@@ -18,6 +18,7 @@ return Application::configure(basePath: dirname(__DIR__))
]); ]);
$middleware->web(append: [ $middleware->web(append: [
\App\Http\Middleware\RedirectLegacyProfileSubdomain::class,
\App\Http\Middleware\HandleInertiaRequests::class, \App\Http\Middleware\HandleInertiaRequests::class,
// Runs on every web request; no-ops for guests, redirects authenticated // Runs on every web request; no-ops for guests, redirects authenticated
// users who have not finished onboarding (e.g. OAuth users awaiting username). // users who have not finished onboarding (e.g. OAuth users awaiting username).

View File

@@ -7,4 +7,5 @@ return [
cPad\Plugins\Artworks\ServiceProvider::class, cPad\Plugins\Artworks\ServiceProvider::class,
cPad\Plugins\News\ServiceProvider::class, cPad\Plugins\News\ServiceProvider::class,
cPad\Plugins\Forum\ServiceProvider::class, cPad\Plugins\Forum\ServiceProvider::class,
cPad\Plugins\Site\ServiceProvider::class,
]; ];

View File

@@ -0,0 +1,23 @@
<?php
return [
'enabled' => (bool) env('SKINBASE_COUNTRIES_ENABLED', true),
'remote_source' => env('SKINBASE_COUNTRIES_REMOTE_SOURCE', 'restcountries'),
'endpoint' => env(
'SKINBASE_COUNTRIES_ENDPOINT',
'https://restcountries.com/v3.1/all?fields=cca2,cca3,ccn3,name,region,subregion,flags,flag'
),
'connect_timeout' => (int) env('SKINBASE_COUNTRIES_CONNECT_TIMEOUT', 5),
'timeout' => (int) env('SKINBASE_COUNTRIES_TIMEOUT', 10),
'retry_times' => (int) env('SKINBASE_COUNTRIES_RETRY_TIMES', 2),
'retry_sleep_ms' => (int) env('SKINBASE_COUNTRIES_RETRY_SLEEP_MS', 250),
'deactivate_missing' => (bool) env('SKINBASE_COUNTRIES_DEACTIVATE_MISSING', false),
'cache_ttl' => (int) env('SKINBASE_COUNTRIES_CACHE_TTL', 86400),
'featured_countries' => array_values(array_filter(array_map(
static fn (string $iso2): string => strtoupper(trim($iso2)),
explode(',', (string) env('SKINBASE_COUNTRIES_FEATURED', 'SI,HR,AT,DE,IT,US')),
))),
'use_local_flags' => (bool) env('SKINBASE_COUNTRIES_USE_LOCAL_FLAGS', true),
'fallback_seed_enabled' => (bool) env('SKINBASE_COUNTRIES_FALLBACK_ENABLED', true),
'fallback_seed_path' => database_path('data/countries-fallback.json'),
];

View File

@@ -50,7 +50,7 @@ return [
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
'lm_studio' => [ 'lm_studio' => [
'base_url' => env('LM_STUDIO_URL', 'http://172.28.16.1:8200'), 'base_url' => env('LM_STUDIO_URL', 'http://192.168.0.100:8200'),
'model' => env('LM_STUDIO_MODEL', 'google/gemma-3-4b'), 'model' => env('LM_STUDIO_MODEL', 'google/gemma-3-4b'),
'timeout' => (int) env('LM_STUDIO_TIMEOUT', 60), 'timeout' => (int) env('LM_STUDIO_TIMEOUT', 60),
'connect_timeout' => (int) env('LM_STUDIO_CONNECT_TIMEOUT', 5), 'connect_timeout' => (int) env('LM_STUDIO_CONNECT_TIMEOUT', 5),

Some files were not shown because too many files have changed in this diff Show More