update
This commit is contained in:
@@ -47,21 +47,73 @@ final class AiTagArtworksCommand extends Command
|
||||
// Prompt
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private const SYSTEM_PROMPT = <<<'PROMPT'
|
||||
You are an expert at analysing visual artwork and generating concise, descriptive tags.
|
||||
private const SYSTEM_PROMPT = <<<'PROMPT'
|
||||
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;
|
||||
|
||||
private const USER_PROMPT = <<<'PROMPT'
|
||||
Analyse the artwork image and return a JSON array of relevant tags.
|
||||
Cover: art style, subject/theme, dominant colours, mood, technique, and medium where visible.
|
||||
private const USER_PROMPT = <<<'PROMPT'
|
||||
Analyse this artwork image and return a JSON array of relevant tags.
|
||||
|
||||
Rules:
|
||||
- Return ONLY a valid JSON array of lowercase strings — no markdown, no explanation.
|
||||
- Each tag must be 1–4 words, no punctuation except hyphens.
|
||||
- Between 6 and 12 tags total.
|
||||
Requirements:
|
||||
- Return ONLY a valid JSON array of lowercase strings.
|
||||
- No markdown, no explanation, no extra text.
|
||||
- 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:
|
||||
["digital painting","fantasy","portrait","dark tones","glowing eyes","detailed","dramatic lighting"]
|
||||
Focus on tags from these groups when visible:
|
||||
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;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -49,7 +49,7 @@ class AvatarsMigrate extends Command
|
||||
*
|
||||
* @var int[]
|
||||
*/
|
||||
protected $sizes = [32, 40, 64, 128, 256, 512];
|
||||
protected $sizes = [32, 40, 64, 80, 96, 128, 256, 512];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
|
||||
163
app/Console/Commands/RecalculateUserXpCommand.php
Normal file
163
app/Console/Commands/RecalculateUserXpCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
50
app/Console/Commands/SyncCountriesCommand.php
Normal file
50
app/Console/Commands/SyncCountriesCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,13 @@ use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
|
||||
use App\Console\Commands\SeedTagInteractionDemoCommand;
|
||||
use App\Console\Commands\EvaluateFeedWeightsCommand;
|
||||
use App\Console\Commands\AiTagArtworksCommand;
|
||||
use App\Console\Commands\SyncCountriesCommand;
|
||||
use App\Console\Commands\CompareFeedAbCommand;
|
||||
use App\Console\Commands\RecalculateTrendingCommand;
|
||||
use App\Console\Commands\RecalculateRankingsCommand;
|
||||
use App\Console\Commands\MetricsSnapshotHourlyCommand;
|
||||
use App\Console\Commands\RecalculateHeatCommand;
|
||||
use App\Jobs\UpdateLeaderboardsJob;
|
||||
use App\Jobs\RankComputeArtworkScoresJob;
|
||||
use App\Jobs\RankBuildListsJob;
|
||||
use App\Uploads\Commands\CleanupUploadsCommand;
|
||||
@@ -48,6 +50,7 @@ class Kernel extends ConsoleKernel
|
||||
EvaluateFeedWeightsCommand::class,
|
||||
CompareFeedAbCommand::class,
|
||||
AiTagArtworksCommand::class,
|
||||
SyncCountriesCommand::class,
|
||||
\App\Console\Commands\MigrateFollows::class,
|
||||
RecalculateTrendingCommand::class,
|
||||
RecalculateRankingsCommand::class,
|
||||
@@ -88,6 +91,12 @@ class Kernel extends ConsoleKernel
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
$schedule->job(new UpdateLeaderboardsJob)
|
||||
->hourlyAt(20)
|
||||
->name('leaderboards-refresh')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// ── Rising Engine (Heat / Momentum) ─────────────────────────────────
|
||||
// Step 1: snapshot metric totals every hour at :00
|
||||
$schedule->command('nova:metrics-snapshot-hourly')
|
||||
@@ -104,6 +113,12 @@ class Kernel extends ConsoleKernel
|
||||
// Step 3: prune old snapshots daily at 04:00
|
||||
$schedule->command('nova:prune-metric-snapshots --keep-days=7')
|
||||
->dailyAt('04:00');
|
||||
|
||||
$schedule->command('skinbase:sync-countries')
|
||||
->monthlyOn(1, '03:40')
|
||||
->name('sync-countries')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
15
app/Events/Achievements/AchievementCheckRequested.php
Normal file
15
app/Events/Achievements/AchievementCheckRequested.php
Normal 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) {}
|
||||
}
|
||||
15
app/Events/Achievements/UserXpUpdated.php
Normal file
15
app/Events/Achievements/UserXpUpdated.php
Normal 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) {}
|
||||
}
|
||||
95
app/Http/Controllers/Admin/CountryAdminController.php
Normal file
95
app/Http/Controllers/Admin/CountryAdminController.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Country;
|
||||
use App\Services\Countries\CountrySyncService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Throwable;
|
||||
|
||||
final class CountryAdminController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$search = trim((string) $request->query('q', ''));
|
||||
|
||||
$countries = Country::query()
|
||||
->when($search !== '', function ($query) use ($search): void {
|
||||
$query->where(function ($countryQuery) use ($search): void {
|
||||
$countryQuery
|
||||
->where('iso2', 'like', '%'.$search.'%')
|
||||
->orWhere('iso3', 'like', '%'.$search.'%')
|
||||
->orWhere('name_common', 'like', '%'.$search.'%')
|
||||
->orWhere('name_official', 'like', '%'.$search.'%');
|
||||
});
|
||||
})
|
||||
->ordered()
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.countries.index', [
|
||||
'countries' => $countries,
|
||||
'search' => $search,
|
||||
]);
|
||||
}
|
||||
|
||||
public function sync(Request $request, CountrySyncService $countrySyncService): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$summary = $countrySyncService->sync();
|
||||
} catch (Throwable $exception) {
|
||||
return redirect()
|
||||
->route('admin.countries.index')
|
||||
->with('error', $exception->getMessage());
|
||||
}
|
||||
|
||||
$message = sprintf(
|
||||
'Countries synced from %s. Inserted %d, updated %d, skipped %d, deactivated %d.',
|
||||
(string) ($summary['source'] ?? 'unknown'),
|
||||
(int) ($summary['inserted'] ?? 0),
|
||||
(int) ($summary['updated'] ?? 0),
|
||||
(int) ($summary['skipped'] ?? 0),
|
||||
(int) ($summary['deactivated'] ?? 0),
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('admin.countries.index')
|
||||
->with('success', $message);
|
||||
}
|
||||
|
||||
public function cpMain(Request $request): View
|
||||
{
|
||||
$view = $this->index($request);
|
||||
|
||||
return view('admin.countries.cpad', $view->getData());
|
||||
}
|
||||
|
||||
public function cpSync(Request $request, CountrySyncService $countrySyncService): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$summary = $countrySyncService->sync();
|
||||
} catch (Throwable $exception) {
|
||||
return redirect()
|
||||
->route('admin.cp.countries.main')
|
||||
->with('msg_error', $exception->getMessage());
|
||||
}
|
||||
|
||||
$message = sprintf(
|
||||
'Countries synced from %s. Inserted %d, updated %d, skipped %d, deactivated %d.',
|
||||
(string) ($summary['source'] ?? 'unknown'),
|
||||
(int) ($summary['inserted'] ?? 0),
|
||||
(int) ($summary['updated'] ?? 0),
|
||||
(int) ($summary['skipped'] ?? 0),
|
||||
(int) ($summary['deactivated'] ?? 0),
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('admin.cp.countries.main')
|
||||
->with('msg_success', $message);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Models\Story;
|
||||
use App\Models\StoryTag;
|
||||
use App\Models\User;
|
||||
use App\Notifications\StoryStatusNotification;
|
||||
use App\Services\StoryPublicationService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -78,6 +79,10 @@ class StoryAdminController extends Controller
|
||||
$story->tags()->sync($validated['tags']);
|
||||
}
|
||||
|
||||
if ($validated['status'] === 'published') {
|
||||
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.stories.edit', ['story' => $story->id])
|
||||
->with('status', 'Story created.');
|
||||
}
|
||||
@@ -95,6 +100,8 @@ class StoryAdminController extends Controller
|
||||
|
||||
public function update(Request $request, Story $story): RedirectResponse
|
||||
{
|
||||
$wasPublished = $story->published_at !== null || $story->status === 'published';
|
||||
|
||||
$validated = $request->validate([
|
||||
'creator_id' => ['required', 'integer', 'exists:users,id'],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
@@ -122,6 +129,10 @@ class StoryAdminController extends Controller
|
||||
|
||||
$story->tags()->sync($validated['tags'] ?? []);
|
||||
|
||||
if (! $wasPublished && $validated['status'] === 'published') {
|
||||
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
|
||||
}
|
||||
|
||||
return back()->with('status', 'Story updated.');
|
||||
}
|
||||
|
||||
@@ -134,14 +145,11 @@ class StoryAdminController extends Controller
|
||||
|
||||
public function publish(Story $story): RedirectResponse
|
||||
{
|
||||
$story->update([
|
||||
'status' => 'published',
|
||||
app(StoryPublicationService::class)->publish($story, 'published', [
|
||||
'published_at' => $story->published_at ?? now(),
|
||||
'reviewed_at' => now(),
|
||||
]);
|
||||
|
||||
$story->creator?->notify(new StoryStatusNotification($story, 'published'));
|
||||
|
||||
return back()->with('status', 'Story published.');
|
||||
}
|
||||
|
||||
@@ -154,16 +162,13 @@ class StoryAdminController extends Controller
|
||||
|
||||
public function approve(Request $request, Story $story): RedirectResponse
|
||||
{
|
||||
$story->update([
|
||||
'status' => 'published',
|
||||
app(StoryPublicationService::class)->publish($story, 'approved', [
|
||||
'published_at' => $story->published_at ?? now(),
|
||||
'reviewed_at' => now(),
|
||||
'reviewed_by_id' => (int) $request->user()->id,
|
||||
'rejected_reason' => null,
|
||||
]);
|
||||
|
||||
$story->creator?->notify(new StoryStatusNotification($story, 'approved'));
|
||||
|
||||
return back()->with('status', 'Story approved and published.');
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMention;
|
||||
use App\Notifications\ArtworkCommentedNotification;
|
||||
use App\Notifications\ArtworkMentionedNotification;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\LegacySmileyMapper;
|
||||
use App\Support\AvatarUrl;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -113,6 +116,7 @@ class ArtworkCommentController extends Controller
|
||||
Cache::forget('comments.latest.all.page1');
|
||||
|
||||
$comment->load(['user', 'user.profile']);
|
||||
$this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null);
|
||||
|
||||
// Record activity event (fire-and-forget; never break the response)
|
||||
try {
|
||||
@@ -204,6 +208,8 @@ class ArtworkCommentController extends Controller
|
||||
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
|
||||
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
||||
'level' => (int) ($user?->level ?? 1),
|
||||
'rank' => (string) ($user?->rank ?? 'Newbie'),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -217,4 +223,48 @@ class ArtworkCommentController extends Controller
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function notifyRecipients(Artwork $artwork, ArtworkComment $comment, User $actor, ?int $parentId): void
|
||||
{
|
||||
$notifiedUserIds = [];
|
||||
$creatorId = (int) ($artwork->user_id ?? 0);
|
||||
|
||||
if ($creatorId > 0 && $creatorId !== (int) $actor->id) {
|
||||
$creator = User::query()->find($creatorId);
|
||||
if ($creator) {
|
||||
$creator->notify(new ArtworkCommentedNotification($artwork, $comment, $actor));
|
||||
$notifiedUserIds[] = (int) $creator->id;
|
||||
}
|
||||
}
|
||||
|
||||
if ($parentId) {
|
||||
$parentUserId = (int) (ArtworkComment::query()->whereKey($parentId)->value('user_id') ?? 0);
|
||||
if ($parentUserId > 0 && $parentUserId !== (int) $actor->id && ! in_array($parentUserId, $notifiedUserIds, true)) {
|
||||
$parentUser = User::query()->find($parentUserId);
|
||||
if ($parentUser) {
|
||||
$parentUser->notify(new ArtworkCommentedNotification($artwork, $comment, $actor));
|
||||
$notifiedUserIds[] = (int) $parentUser->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
User::query()
|
||||
->whereIn(
|
||||
'id',
|
||||
UserMention::query()
|
||||
->where('comment_id', (int) $comment->id)
|
||||
->pluck('mentioned_user_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->unique()
|
||||
->all()
|
||||
)
|
||||
->get()
|
||||
->each(function (User $mentionedUser) use ($artwork, $comment, $actor): void {
|
||||
if ((int) $mentionedUser->id === (int) $actor->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mentionedUser->notify(new ArtworkMentionedNotification($artwork, $comment, $actor));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Events\Achievements\AchievementCheckRequested;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Notifications\ArtworkLikedNotification;
|
||||
use App\Services\FollowService;
|
||||
use App\Services\UserStatsService;
|
||||
use App\Services\XPService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -14,11 +18,25 @@ use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class ArtworkInteractionController extends Controller
|
||||
{
|
||||
public function bookmark(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$this->toggleSimple(
|
||||
request: $request,
|
||||
table: 'artwork_bookmarks',
|
||||
keyColumns: ['user_id', 'artwork_id'],
|
||||
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
|
||||
insertPayload: ['created_at' => now(), 'updated_at' => now()],
|
||||
requiredTable: 'artwork_bookmarks'
|
||||
);
|
||||
|
||||
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
|
||||
}
|
||||
|
||||
public function favorite(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$state = $request->boolean('state', true);
|
||||
|
||||
$this->toggleSimple(
|
||||
$changed = $this->toggleSimple(
|
||||
request: $request,
|
||||
table: 'artwork_favourites',
|
||||
keyColumns: ['user_id', 'artwork_id'],
|
||||
@@ -33,7 +51,7 @@ final class ArtworkInteractionController extends Controller
|
||||
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
|
||||
if ($creatorId) {
|
||||
$svc = app(UserStatsService::class);
|
||||
if ($state) {
|
||||
if ($state && $changed) {
|
||||
$svc->incrementFavoritesReceived($creatorId);
|
||||
$svc->setLastActiveAt((int) $request->user()->id);
|
||||
|
||||
@@ -46,7 +64,7 @@ final class ArtworkInteractionController extends Controller
|
||||
targetId: $artworkId,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
} else {
|
||||
} elseif (! $state && $changed) {
|
||||
$svc->decrementFavoritesReceived($creatorId);
|
||||
}
|
||||
}
|
||||
@@ -56,7 +74,7 @@ final class ArtworkInteractionController extends Controller
|
||||
|
||||
public function like(Request $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$this->toggleSimple(
|
||||
$changed = $this->toggleSimple(
|
||||
request: $request,
|
||||
table: 'artwork_likes',
|
||||
keyColumns: ['user_id', 'artwork_id'],
|
||||
@@ -67,6 +85,20 @@ final class ArtworkInteractionController extends Controller
|
||||
|
||||
$this->syncArtworkStats($artworkId);
|
||||
|
||||
if ($request->boolean('state', true) && $changed) {
|
||||
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
|
||||
$actorId = (int) $request->user()->id;
|
||||
if ($creatorId > 0 && $creatorId !== $actorId) {
|
||||
app(XPService::class)->awardArtworkLikeReceived($creatorId, $artworkId, $actorId);
|
||||
$creator = \App\Models\User::query()->find($creatorId);
|
||||
$artwork = Artwork::query()->find($artworkId);
|
||||
if ($creator && $artwork) {
|
||||
$creator->notify(new ArtworkLikedNotification($artwork, $request->user()));
|
||||
}
|
||||
event(new AchievementCheckRequested($creatorId));
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
|
||||
}
|
||||
|
||||
@@ -104,8 +136,10 @@ final class ArtworkInteractionController extends Controller
|
||||
return response()->json(['message' => 'Cannot follow yourself'], 422);
|
||||
}
|
||||
|
||||
$svc = app(FollowService::class);
|
||||
$state = $request->boolean('state', true);
|
||||
$svc = app(FollowService::class);
|
||||
$state = $request->has('state')
|
||||
? $request->boolean('state')
|
||||
: ! $request->isMethod('delete');
|
||||
|
||||
if ($state) {
|
||||
$svc->follow($actorId, $userId);
|
||||
@@ -148,7 +182,7 @@ final class ArtworkInteractionController extends Controller
|
||||
array $keyValues,
|
||||
array $insertPayload,
|
||||
string $requiredTable
|
||||
): void {
|
||||
): bool {
|
||||
if (! Schema::hasTable($requiredTable)) {
|
||||
abort(422, 'Interaction unavailable');
|
||||
}
|
||||
@@ -163,10 +197,13 @@ final class ArtworkInteractionController extends Controller
|
||||
if ($state) {
|
||||
if (! $query->exists()) {
|
||||
DB::table($table)->insert(array_merge($keyValues, $insertPayload));
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
$query->delete();
|
||||
return $query->delete() > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function syncArtworkStats(int $artworkId): void
|
||||
@@ -194,6 +231,10 @@ final class ArtworkInteractionController extends Controller
|
||||
|
||||
private function statusPayload(int $viewerId, int $artworkId): array
|
||||
{
|
||||
$isBookmarked = Schema::hasTable('artwork_bookmarks')
|
||||
? DB::table('artwork_bookmarks')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
|
||||
: false;
|
||||
|
||||
$isFavorited = Schema::hasTable('artwork_favourites')
|
||||
? DB::table('artwork_favourites')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
|
||||
: false;
|
||||
@@ -206,15 +247,21 @@ final class ArtworkInteractionController extends Controller
|
||||
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
|
||||
: 0;
|
||||
|
||||
$bookmarks = Schema::hasTable('artwork_bookmarks')
|
||||
? (int) DB::table('artwork_bookmarks')->where('artwork_id', $artworkId)->count()
|
||||
: 0;
|
||||
|
||||
$likes = Schema::hasTable('artwork_likes')
|
||||
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
|
||||
: 0;
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'is_bookmarked' => $isBookmarked,
|
||||
'is_favorited' => $isFavorited,
|
||||
'is_liked' => $isLiked,
|
||||
'stats' => [
|
||||
'bookmarks' => $bookmarks,
|
||||
'favorites' => $favorites,
|
||||
'likes' => $likes,
|
||||
],
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkStatsService;
|
||||
use App\Services\XPService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -26,7 +27,10 @@ use Illuminate\Http\Request;
|
||||
*/
|
||||
final class ArtworkViewController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ArtworkStatsService $stats) {}
|
||||
public function __construct(
|
||||
private readonly ArtworkStatsService $stats,
|
||||
private readonly XPService $xp,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, int $id): JsonResponse
|
||||
{
|
||||
@@ -52,6 +56,16 @@ final class ArtworkViewController extends Controller
|
||||
// Defer to Redis when available, fall back to direct DB increment.
|
||||
$this->stats->incrementViews((int) $artwork->id, 1, defer: true);
|
||||
|
||||
$viewerId = $request->user()?->id;
|
||||
if ($artwork->user_id !== null && (int) $artwork->user_id !== (int) ($viewerId ?? 0)) {
|
||||
$this->xp->awardArtworkViewReceived(
|
||||
(int) $artwork->user_id,
|
||||
(int) $artwork->id,
|
||||
$viewerId,
|
||||
(string) $request->ip(),
|
||||
);
|
||||
}
|
||||
|
||||
// Mark this session so the artwork is not counted again.
|
||||
if ($request->hasSession()) {
|
||||
$request->session()->put($sessionKey, true);
|
||||
|
||||
@@ -36,6 +36,10 @@ final class CommunityActivityController extends Controller
|
||||
|
||||
private function resolveFilter(Request $request): string
|
||||
{
|
||||
if ($request->filled('type') && ! $request->filled('filter')) {
|
||||
return (string) $request->query('type', 'all');
|
||||
}
|
||||
|
||||
if ($request->boolean('following') && ! $request->filled('filter')) {
|
||||
return 'following';
|
||||
}
|
||||
|
||||
35
app/Http/Controllers/Api/LeaderboardController.php
Normal file
35
app/Http/Controllers/Api/LeaderboardController.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Leaderboard;
|
||||
use App\Services\LeaderboardService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class LeaderboardController extends Controller
|
||||
{
|
||||
public function creators(Request $request, LeaderboardService $leaderboards): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$leaderboards->getLeaderboard(Leaderboard::TYPE_CREATOR, (string) $request->query('period', 'weekly'))
|
||||
);
|
||||
}
|
||||
|
||||
public function artworks(Request $request, LeaderboardService $leaderboards): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$leaderboards->getLeaderboard(Leaderboard::TYPE_ARTWORK, (string) $request->query('period', 'weekly'))
|
||||
);
|
||||
}
|
||||
|
||||
public function stories(Request $request, LeaderboardService $leaderboards): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$leaderboards->getLeaderboard(Leaderboard::TYPE_STORY, (string) $request->query('period', 'weekly'))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Services\Posts\NotificationDigestService;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Controller;
|
||||
@@ -14,48 +14,24 @@ use App\Http\Controllers\Controller;
|
||||
*/
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function __construct(private NotificationDigestService $digest) {}
|
||||
public function __construct(private NotificationService $notifications) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$notifications = $user->notifications()
|
||||
->latest()
|
||||
->limit(200) // aggregate from last 200 raw notifs
|
||||
->get();
|
||||
|
||||
$digested = $this->digest->aggregate($notifications);
|
||||
|
||||
// Simple manual pagination on the digested array
|
||||
$perPage = 20;
|
||||
$total = count($digested);
|
||||
$sliced = array_slice($digested, ($page - 1) * $perPage, $perPage);
|
||||
$unread = $user->unreadNotifications()->count();
|
||||
|
||||
return response()->json([
|
||||
'data' => array_values($sliced),
|
||||
'unread_count' => $unread,
|
||||
'meta' => [
|
||||
'total' => $total,
|
||||
'current_page' => $page,
|
||||
'last_page' => (int) ceil($total / $perPage) ?: 1,
|
||||
'per_page' => $perPage,
|
||||
],
|
||||
]);
|
||||
return response()->json(
|
||||
$this->notifications->listForUser($request->user(), (int) $request->query('page', 1), 20)
|
||||
);
|
||||
}
|
||||
|
||||
public function readAll(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->unreadNotifications()->update(['read_at' => now()]);
|
||||
$this->notifications->markAllRead($request->user());
|
||||
return response()->json(['message' => 'All notifications marked as read.']);
|
||||
}
|
||||
|
||||
public function markRead(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$notif = $request->user()->notifications()->findOrFail($id);
|
||||
$notif->markAsRead();
|
||||
$this->notifications->markRead($request->user(), $id);
|
||||
return response()->json(['message' => 'Notification marked as read.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ class PostCommentController extends Controller
|
||||
'username' => $comment->user->username,
|
||||
'name' => $comment->user->name,
|
||||
'avatar' => $comment->user->profile?->avatar_url ?? null,
|
||||
'level' => (int) ($comment->user->level ?? 1),
|
||||
'rank' => (string) ($comment->user->rank ?? 'Newbie'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -7,9 +7,8 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonInterface;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\ThumbnailService;
|
||||
use App\Support\AvatarUrl;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -57,20 +56,9 @@ final class ProfileApiController extends Controller
|
||||
$perPage = 24;
|
||||
$paginator = $query->cursorPaginate($perPage);
|
||||
|
||||
$data = collect($paginator->items())->map(function (Artwork $art) {
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
return [
|
||||
'id' => $art->id,
|
||||
'name' => $art->title,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
'username' => $art->user->username ?? null,
|
||||
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
|
||||
'published_at' => $art->published_at,
|
||||
];
|
||||
})->values();
|
||||
$data = collect($paginator->items())
|
||||
->map(fn (Artwork $art) => $this->mapArtworkCardPayload($art))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
@@ -85,7 +73,8 @@ final class ProfileApiController extends Controller
|
||||
*/
|
||||
public function favourites(Request $request, string $username): JsonResponse
|
||||
{
|
||||
if (! Schema::hasTable('user_favorites')) {
|
||||
$favouriteTable = $this->resolveFavouriteTable();
|
||||
if ($favouriteTable === null) {
|
||||
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
||||
}
|
||||
|
||||
@@ -95,16 +84,18 @@ final class ProfileApiController extends Controller
|
||||
}
|
||||
|
||||
$perPage = 24;
|
||||
$cursor = $request->input('cursor');
|
||||
$offset = max(0, (int) base64_decode((string) $request->input('cursor', ''), true));
|
||||
|
||||
$favIds = DB::table('user_favorites as uf')
|
||||
->join('artworks as a', 'a.id', '=', 'uf.artwork_id')
|
||||
->where('uf.user_id', $user->id)
|
||||
$favIds = DB::table($favouriteTable . ' as af')
|
||||
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
|
||||
->where('af.user_id', $user->id)
|
||||
->whereNull('a.deleted_at')
|
||||
->where('a.is_public', true)
|
||||
->where('a.is_approved', true)
|
||||
->orderByDesc('uf.created_at')
|
||||
->offset($cursor ? (int) base64_decode($cursor) : 0)
|
||||
->whereNotNull('a.published_at')
|
||||
->orderByDesc('af.created_at')
|
||||
->orderByDesc('af.artwork_id')
|
||||
->offset($offset)
|
||||
->limit($perPage + 1)
|
||||
->pluck('a.id');
|
||||
|
||||
@@ -120,24 +111,14 @@ final class ProfileApiController extends Controller
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$data = $favIds->filter(fn ($id) => $indexed->has($id))->map(function ($id) use ($indexed) {
|
||||
$art = $indexed[$id];
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
return [
|
||||
'id' => $art->id,
|
||||
'name' => $art->title,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
'username' => $art->user->username ?? null,
|
||||
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
|
||||
];
|
||||
})->values();
|
||||
$data = $favIds
|
||||
->filter(fn ($id) => $indexed->has($id))
|
||||
->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id]))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'next_cursor' => null, // Simple offset pagination for now
|
||||
'next_cursor' => $hasMore ? base64_encode((string) ($offset + $perPage)) : null,
|
||||
'has_more' => $hasMore,
|
||||
]);
|
||||
}
|
||||
@@ -174,4 +155,48 @@ final class ProfileApiController extends Controller
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||
}
|
||||
|
||||
private function resolveFavouriteTable(): ?string
|
||||
{
|
||||
foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) {
|
||||
if (Schema::hasTable($table)) {
|
||||
return $table;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapArtworkCardPayload(Artwork $art): array
|
||||
{
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
|
||||
return [
|
||||
'id' => $art->id,
|
||||
'name' => $art->title,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
'username' => $art->user->username ?? null,
|
||||
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
|
||||
'published_at' => $this->formatIsoDate($art->published_at),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatIsoDate(mixed $value): ?string
|
||||
{
|
||||
if ($value instanceof CarbonInterface) {
|
||||
return $value->toISOString();
|
||||
}
|
||||
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format(DATE_ATOM);
|
||||
}
|
||||
|
||||
return is_string($value) ? $value : null;
|
||||
}
|
||||
}
|
||||
|
||||
34
app/Http/Controllers/Api/SocialActivityController.php
Normal file
34
app/Http/Controllers/Api/SocialActivityController.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ActivityService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class SocialActivityController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ActivityService $activity) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$filter = (string) $request->query('filter', 'all');
|
||||
|
||||
if ($this->activity->requiresAuthentication($filter) && ! $request->user()) {
|
||||
return response()->json(['error' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
return response()->json(
|
||||
$this->activity->communityFeed(
|
||||
viewer: $request->user(),
|
||||
filter: $filter,
|
||||
page: (int) $request->query('page', 1),
|
||||
perPage: (int) $request->query('per_page', 20),
|
||||
actorUserId: $request->filled('user_id') ? (int) $request->query('user_id') : null,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
212
app/Http/Controllers/Api/SocialCompatibilityController.php
Normal file
212
app/Http/Controllers/Api/SocialCompatibilityController.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryBookmark;
|
||||
use App\Services\SocialService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class SocialCompatibilityController extends Controller
|
||||
{
|
||||
public function __construct(private readonly SocialService $social) {}
|
||||
|
||||
public function like(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'entity_type' => ['required', 'string', 'in:artwork,story'],
|
||||
'entity_id' => ['required', 'integer'],
|
||||
'state' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$state = array_key_exists('state', $payload)
|
||||
? (bool) $payload['state']
|
||||
: ! $request->isMethod('delete');
|
||||
|
||||
if ($payload['entity_type'] === 'story') {
|
||||
$story = Story::published()->findOrFail((int) $payload['entity_id']);
|
||||
|
||||
$result = $this->social->toggleStoryLike($request->user(), $story, $state);
|
||||
|
||||
return response()->json([
|
||||
'ok' => (bool) ($result['ok'] ?? true),
|
||||
'liked' => (bool) ($result['liked'] ?? false),
|
||||
'likes_count' => (int) ($result['likes_count'] ?? 0),
|
||||
'is_liked' => (bool) ($result['liked'] ?? false),
|
||||
'stats' => [
|
||||
'likes' => (int) ($result['likes_count'] ?? 0),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$artworkId = (int) $payload['entity_id'];
|
||||
abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404);
|
||||
|
||||
return app(ArtworkInteractionController::class)->like(
|
||||
$request->merge(['state' => $state]),
|
||||
$artworkId,
|
||||
);
|
||||
}
|
||||
|
||||
public function comments(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'entity_type' => ['required', 'string', 'in:artwork,story'],
|
||||
'entity_id' => ['required', 'integer'],
|
||||
'content' => [$request->isMethod('get') ? 'nullable' : 'required', 'string', 'min:1', 'max:10000'],
|
||||
'parent_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
if ($payload['entity_type'] === 'story') {
|
||||
if ($request->isMethod('get')) {
|
||||
$story = Story::published()->findOrFail((int) $payload['entity_id']);
|
||||
|
||||
return response()->json(
|
||||
$this->social->listStoryComments($story, $request->user()?->id, (int) $request->query('page', 1), 20)
|
||||
);
|
||||
}
|
||||
|
||||
$story = Story::published()->findOrFail((int) $payload['entity_id']);
|
||||
$comment = $this->social->addStoryComment(
|
||||
$request->user(),
|
||||
$story,
|
||||
(string) $payload['content'],
|
||||
isset($payload['parent_id']) ? (int) $payload['parent_id'] : null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->social->formatComment($comment, (int) $request->user()->id, true),
|
||||
], 201);
|
||||
}
|
||||
|
||||
$artworkId = (int) $payload['entity_id'];
|
||||
abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404);
|
||||
|
||||
if ($request->isMethod('get')) {
|
||||
return app(ArtworkCommentController::class)->index($request, $artworkId);
|
||||
}
|
||||
|
||||
return app(ArtworkCommentController::class)->store(
|
||||
$request->merge([
|
||||
'content' => $payload['content'],
|
||||
'parent_id' => $payload['parent_id'] ?? null,
|
||||
]),
|
||||
$artworkId,
|
||||
);
|
||||
}
|
||||
|
||||
public function bookmark(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'entity_type' => ['required', 'string', 'in:artwork,story'],
|
||||
'entity_id' => ['required', 'integer'],
|
||||
'state' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$state = array_key_exists('state', $payload)
|
||||
? (bool) $payload['state']
|
||||
: ! $request->isMethod('delete');
|
||||
|
||||
if ($payload['entity_type'] === 'story') {
|
||||
$story = Story::published()->findOrFail((int) $payload['entity_id']);
|
||||
|
||||
$result = $this->social->toggleStoryBookmark($request->user(), $story, $state);
|
||||
|
||||
return response()->json([
|
||||
'ok' => (bool) ($result['ok'] ?? true),
|
||||
'bookmarked' => (bool) ($result['bookmarked'] ?? false),
|
||||
'bookmarks_count' => (int) ($result['bookmarks_count'] ?? 0),
|
||||
'is_bookmarked' => (bool) ($result['bookmarked'] ?? false),
|
||||
'stats' => [
|
||||
'bookmarks' => (int) ($result['bookmarks_count'] ?? 0),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$artworkId = (int) $payload['entity_id'];
|
||||
abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404);
|
||||
|
||||
return app(ArtworkInteractionController::class)->bookmark(
|
||||
$request->merge(['state' => $state]),
|
||||
$artworkId,
|
||||
);
|
||||
}
|
||||
|
||||
public function bookmarks(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'entity_type' => ['nullable', 'string', 'in:artwork,story'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||
]);
|
||||
|
||||
$perPage = (int) ($payload['per_page'] ?? 20);
|
||||
$userId = (int) $request->user()->id;
|
||||
$type = $payload['entity_type'] ?? null;
|
||||
|
||||
$items = collect();
|
||||
|
||||
if ($type === null || $type === 'artwork') {
|
||||
$items = $items->concat(
|
||||
Schema::hasTable('artwork_bookmarks')
|
||||
? DB::table('artwork_bookmarks')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_bookmarks.artwork_id')
|
||||
->where('artwork_bookmarks.user_id', $userId)
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->select([
|
||||
'artwork_bookmarks.created_at as saved_at',
|
||||
'artworks.id',
|
||||
'artworks.title',
|
||||
'artworks.slug',
|
||||
])
|
||||
->latest('artwork_bookmarks.created_at')
|
||||
->limit($perPage)
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'type' => 'artwork',
|
||||
'id' => (int) $row->id,
|
||||
'title' => (string) $row->title,
|
||||
'url' => route('art.show', ['id' => (int) $row->id, 'slug' => Str::slug((string) ($row->slug ?: $row->title)) ?: (string) $row->id]),
|
||||
'saved_at' => Carbon::parse($row->saved_at)->toIso8601String(),
|
||||
])
|
||||
: collect()
|
||||
);
|
||||
}
|
||||
|
||||
if ($type === null || $type === 'story') {
|
||||
$items = $items->concat(
|
||||
StoryBookmark::query()
|
||||
->with('story:id,slug,title')
|
||||
->where('user_id', $userId)
|
||||
->latest('created_at')
|
||||
->limit($perPage)
|
||||
->get()
|
||||
->filter(fn (StoryBookmark $bookmark) => $bookmark->story !== null)
|
||||
->map(fn (StoryBookmark $bookmark) => [
|
||||
'type' => 'story',
|
||||
'id' => (int) $bookmark->story->id,
|
||||
'title' => (string) $bookmark->story->title,
|
||||
'url' => route('stories.show', ['slug' => $bookmark->story->slug]),
|
||||
'saved_at' => $bookmark->created_at?->toIso8601String(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $items
|
||||
->sortByDesc('saved_at')
|
||||
->take($perPage)
|
||||
->values()
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Api/StoryCommentController.php
Normal file
58
app/Http/Controllers/Api/StoryCommentController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryComment;
|
||||
use App\Services\SocialService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class StoryCommentController extends Controller
|
||||
{
|
||||
public function __construct(private readonly SocialService $social) {}
|
||||
|
||||
public function index(Request $request, int $storyId): JsonResponse
|
||||
{
|
||||
$story = Story::published()->findOrFail($storyId);
|
||||
|
||||
return response()->json(
|
||||
$this->social->listStoryComments($story, $request->user()?->id, (int) $request->query('page', 1), 20)
|
||||
);
|
||||
}
|
||||
|
||||
public function store(Request $request, int $storyId): JsonResponse
|
||||
{
|
||||
$story = Story::published()->findOrFail($storyId);
|
||||
|
||||
$payload = $request->validate([
|
||||
'content' => ['required', 'string', 'min:1', 'max:10000'],
|
||||
'parent_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$comment = $this->social->addStoryComment(
|
||||
$request->user(),
|
||||
$story,
|
||||
(string) $payload['content'],
|
||||
isset($payload['parent_id']) ? (int) $payload['parent_id'] : null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->social->formatComment($comment, $request->user()->id, true),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $storyId, int $commentId): JsonResponse
|
||||
{
|
||||
$comment = StoryComment::query()
|
||||
->where('story_id', $storyId)
|
||||
->findOrFail($commentId);
|
||||
|
||||
$this->social->deleteStoryComment($request->user(), $comment);
|
||||
|
||||
return response()->json(['message' => 'Comment deleted.']);
|
||||
}
|
||||
}
|
||||
34
app/Http/Controllers/Api/StoryInteractionController.php
Normal file
34
app/Http/Controllers/Api/StoryInteractionController.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Story;
|
||||
use App\Services\SocialService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class StoryInteractionController extends Controller
|
||||
{
|
||||
public function __construct(private readonly SocialService $social) {}
|
||||
|
||||
public function like(Request $request, int $storyId): JsonResponse
|
||||
{
|
||||
$story = Story::published()->findOrFail($storyId);
|
||||
|
||||
return response()->json(
|
||||
$this->social->toggleStoryLike($request->user(), $story, $request->boolean('state', true))
|
||||
);
|
||||
}
|
||||
|
||||
public function bookmark(Request $request, int $storyId): JsonResponse
|
||||
{
|
||||
$story = Story::published()->findOrFail($storyId);
|
||||
|
||||
return response()->json(
|
||||
$this->social->toggleStoryBookmark($request->user(), $story, $request->boolean('state', true))
|
||||
);
|
||||
}
|
||||
}
|
||||
18
app/Http/Controllers/Api/UserAchievementsController.php
Normal file
18
app/Http/Controllers/Api/UserAchievementsController.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AchievementService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class UserAchievementsController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, AchievementService $achievements): JsonResponse
|
||||
{
|
||||
return response()->json($achievements->summary($request->user()->id));
|
||||
}
|
||||
}
|
||||
18
app/Http/Controllers/Api/UserXpController.php
Normal file
18
app/Http/Controllers/Api/UserXpController.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\XPService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class UserXpController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, XPService $xp): JsonResponse
|
||||
{
|
||||
return response()->json($xp->summary($request->user()->id));
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,10 @@ final class ArtworkDownloadController extends Controller
|
||||
}
|
||||
|
||||
$filePath = $this->resolveOriginalPath($hash, $ext);
|
||||
|
||||
$this->recordDownload($request, $artwork->id);
|
||||
$this->incrementDownloadCountIfAvailable($artwork->id);
|
||||
|
||||
if (! File::isFile($filePath)) {
|
||||
Log::warning('Artwork original file missing for download.', [
|
||||
'artwork_id' => $artwork->id,
|
||||
@@ -55,8 +59,6 @@ final class ArtworkDownloadController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->recordDownload($request, $artwork->id);
|
||||
$this->incrementDownloadCountIfAvailable($artwork->id);
|
||||
|
||||
$downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext);
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ class LatestController extends Controller
|
||||
$perPage = 21;
|
||||
|
||||
$artworks = $this->artworks->browsePublicArtworks($perPage);
|
||||
$artworks->getCollection()->load([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
]);
|
||||
|
||||
$artworks->getCollection()->transform(function (Artwork $artwork) {
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
@@ -34,10 +38,18 @@ class LatestController extends Controller
|
||||
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
|
||||
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
|
||||
'category_name' => $categoryName,
|
||||
'category_slug' => $primaryCategory?->slug ?? '',
|
||||
'gid_num' => $gid,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
'username' => $artwork->user->username ?? '',
|
||||
'user_id' => $artwork->user->id,
|
||||
'avatar_hash' => $artwork->user->profile->avatar_hash ?? null,
|
||||
'avatar_url' => \App\Support\AvatarUrl::forUser((int) $artwork->user->id, $artwork->user->profile->avatar_hash ?? null, 64),
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'published_at' => $artwork->published_at, // required by CursorPaginator
|
||||
];
|
||||
});
|
||||
|
||||
@@ -3,16 +3,67 @@
|
||||
namespace App\Http\Controllers\Dashboard;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ReceivedCommentsInboxService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
public function __construct(private readonly ReceivedCommentsInboxService $inbox) {}
|
||||
|
||||
public function received(Request $request): View
|
||||
{
|
||||
$user = $request->user();
|
||||
// Minimal placeholder: real implementation should query comments received or made
|
||||
$comments = [];
|
||||
$search = trim((string) $request->query('q', ''));
|
||||
$sort = strtolower((string) $request->query('sort', 'newest'));
|
||||
|
||||
return view('dashboard.comments', ['comments' => $comments]);
|
||||
if (! in_array($sort, ['newest', 'oldest'], true)) {
|
||||
$sort = 'newest';
|
||||
}
|
||||
|
||||
$baseQuery = $this->inbox->queryForUser($user)
|
||||
->with(['user.profile', 'artwork']);
|
||||
|
||||
if ($search !== '') {
|
||||
$baseQuery->where(function ($query) use ($search): void {
|
||||
$query->where('content', 'like', '%' . $search . '%')
|
||||
->orWhere('raw_content', 'like', '%' . $search . '%')
|
||||
->orWhereHas('artwork', function ($artworkQuery) use ($search): void {
|
||||
$artworkQuery->where('title', 'like', '%' . $search . '%')
|
||||
->orWhere('slug', 'like', '%' . $search . '%');
|
||||
})
|
||||
->orWhereHas('user', function ($userQuery) use ($search): void {
|
||||
$userQuery->where('username', 'like', '%' . $search . '%')
|
||||
->orWhere('name', 'like', '%' . $search . '%');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$orderedQuery = (clone $baseQuery)
|
||||
->orderBy('created_at', $sort === 'oldest' ? 'asc' : 'desc');
|
||||
|
||||
$comments = $orderedQuery->paginate(12)->withQueryString();
|
||||
|
||||
$statsBaseQuery = clone $baseQuery;
|
||||
$freshlyClearedCount = $this->inbox->unreadCountForUser($user);
|
||||
$totalComments = (clone $statsBaseQuery)->count();
|
||||
$recentComments = (clone $statsBaseQuery)->where('created_at', '>=', now()->subDays(7))->count();
|
||||
$uniqueCommenters = (clone $statsBaseQuery)->distinct('user_id')->count('user_id');
|
||||
$activeArtworks = (clone $statsBaseQuery)->distinct('artwork_id')->count('artwork_id');
|
||||
|
||||
$this->inbox->markInboxRead($user);
|
||||
|
||||
return view('dashboard.comments', [
|
||||
'comments' => $comments,
|
||||
'search' => $search,
|
||||
'sort' => $sort,
|
||||
'freshlyClearedCount' => $freshlyClearedCount,
|
||||
'stats' => [
|
||||
'total' => $totalComments,
|
||||
'recent' => $recentComments,
|
||||
'commenters' => $uniqueCommenters,
|
||||
'artworks' => $activeArtworks,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Dashboard;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DashboardPreference;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardPreferenceController extends Controller
|
||||
{
|
||||
public function updateShortcuts(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'pinned_spaces' => ['present', 'array', 'max:' . DashboardPreference::MAX_PINNED_SPACES],
|
||||
'pinned_spaces.*' => ['string'],
|
||||
]);
|
||||
|
||||
$pinnedSpaces = DashboardPreference::sanitizePinnedSpaces($validated['pinned_spaces'] ?? []);
|
||||
|
||||
DashboardPreference::query()->updateOrCreate(
|
||||
['user_id' => $request->user()->id],
|
||||
['pinned_spaces' => $pinnedSpaces]
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'pinned_spaces' => $pinnedSpaces,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
25
app/Http/Controllers/Dashboard/NotificationController.php
Normal file
25
app/Http/Controllers/Dashboard/NotificationController.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Dashboard;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function __construct(private readonly NotificationService $notifications) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$payload = $this->notifications->listForUser($request->user(), $page, 15);
|
||||
|
||||
return view('dashboard.notifications', [
|
||||
'notifications' => collect($payload['data'] ?? []),
|
||||
'notificationsMeta' => $payload['meta'] ?? [],
|
||||
'unreadCount' => (int) ($payload['unread_count'] ?? 0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,25 +5,60 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\DashboardPreference;
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
use App\Services\ReceivedCommentsInboxService;
|
||||
use App\Services\XPService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class DashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly XPService $xp,
|
||||
private readonly ReceivedCommentsInboxService $receivedCommentsInbox,
|
||||
) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$xpSummary = $this->xp->summary((int) $user->id);
|
||||
$artworksCount = Artwork::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
$storiesCount = Story::query()->where('creator_id', $user->id)->count();
|
||||
$followersCount = (int) DB::table('user_followers')->where('user_id', $user->id)->count();
|
||||
$followingCount = (int) DB::table('user_followers')->where('follower_id', $user->id)->count();
|
||||
$favoritesCount = (int) DB::table('artwork_favourites')->where('user_id', $user->id)->count();
|
||||
$unreadNotificationsCount = $user->unreadNotifications()->count();
|
||||
$receivedCommentsCount = $this->receivedCommentsInbox->unreadCountForUser($user);
|
||||
$isCreator = $artworksCount > 0;
|
||||
$pinnedSpaces = DashboardPreference::pinnedSpacesForUser($user);
|
||||
|
||||
return view('dashboard', [
|
||||
'page_title' => 'Dashboard',
|
||||
'dashboard_user_name' => $user?->username ?: $user?->name ?: 'Creator',
|
||||
'dashboard_is_creator' => Artwork::query()->where('user_id', $user->id)->exists(),
|
||||
'dashboard_is_creator' => $isCreator,
|
||||
'dashboard_level' => $xpSummary['level'],
|
||||
'dashboard_rank' => $xpSummary['rank'],
|
||||
'dashboard_received_comments_count' => $receivedCommentsCount,
|
||||
'dashboard_overview' => [
|
||||
'artworks' => $artworksCount,
|
||||
'stories' => $storiesCount,
|
||||
'followers' => $followersCount,
|
||||
'following' => $followingCount,
|
||||
'favorites' => $favoritesCount,
|
||||
'notifications' => $unreadNotificationsCount,
|
||||
'received_comments' => $receivedCommentsCount,
|
||||
],
|
||||
'dashboard_preferences' => [
|
||||
'pinned_spaces' => $pinnedSpaces,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -195,6 +230,8 @@ final class DashboardController extends Controller
|
||||
'username' => $artwork->user?->username,
|
||||
'name' => $artwork->user?->name,
|
||||
'url' => $artwork->user?->username ? '/@' . $artwork->user->username : null,
|
||||
'level' => (int) ($artwork->user?->level ?? 1),
|
||||
'rank' => (string) ($artwork->user?->rank ?? 'Newbie'),
|
||||
],
|
||||
];
|
||||
})
|
||||
@@ -238,6 +275,8 @@ final class DashboardController extends Controller
|
||||
'users.id',
|
||||
'users.username',
|
||||
'users.name',
|
||||
'users.level',
|
||||
'users.rank',
|
||||
'up.avatar_hash',
|
||||
DB::raw('COALESCE(us.followers_count, 0) as followers_count'),
|
||||
DB::raw('COALESCE(us.uploads_count, 0) as uploads_count'),
|
||||
@@ -255,6 +294,8 @@ final class DashboardController extends Controller
|
||||
'name' => $row->name,
|
||||
'url' => $username !== '' ? '/@' . $username : null,
|
||||
'avatar' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
|
||||
'level' => (int) ($row->level ?? 1),
|
||||
'rank' => (string) ($row->rank ?? 'Newbie'),
|
||||
'followers_count' => (int) $row->followers_count,
|
||||
'uploads_count' => (int) $row->uploads_count,
|
||||
];
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class GalleryController extends Controller
|
||||
{
|
||||
public function show(Request $request, $userId, $username = null)
|
||||
{
|
||||
$user = User::find((int)$userId);
|
||||
if (! $user) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$hits = 20;
|
||||
|
||||
$query = Artwork::where('user_id', $user->id)
|
||||
->approved()
|
||||
->published()
|
||||
->public()
|
||||
->orderByDesc('published_at');
|
||||
|
||||
$total = (int) $query->count();
|
||||
|
||||
$artworks = $query->skip(($page - 1) * $hits)->take($hits)->get();
|
||||
|
||||
return view('legacy::gallery', [
|
||||
'user' => $user,
|
||||
'artworks' => $artworks,
|
||||
'page' => $page,
|
||||
'hits' => $hits,
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BuddiesController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$perPage = 50;
|
||||
|
||||
try {
|
||||
$query = DB::table('friends_list as t1')
|
||||
->leftJoin('users as t2', 't1.user_id', '=', 't2.id')
|
||||
->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id')
|
||||
->where('t1.friend_id', $user->id)
|
||||
->select('t1.id', 't1.user_id', 't1.friend_id', 't2.name as uname', 'p.avatar_hash as icon', 't1.date_added')
|
||||
->orderByDesc('t1.date_added');
|
||||
|
||||
$followers = $query->paginate($perPage)->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$followers = collect();
|
||||
}
|
||||
|
||||
$page_title = ($user->name ?? $user->username ?? 'User') . ': Followers';
|
||||
|
||||
return view('legacy::buddies', compact('followers', 'page_title'));
|
||||
}
|
||||
}
|
||||
62
app/Http/Controllers/Legacy/CategoryRedirectController.php
Normal file
62
app/Http/Controllers/Legacy/CategoryRedirectController.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CategoryRedirectController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, string $group, ?string $slug = null, ?string $id = null): RedirectResponse
|
||||
{
|
||||
$groupSlug = strtolower(trim($group, '/'));
|
||||
$slugPart = strtolower(trim((string) $slug, '/'));
|
||||
|
||||
$category = $this->resolveCategory($groupSlug, $slugPart, $id);
|
||||
|
||||
if ($category && $category->contentType) {
|
||||
$target = $category->url;
|
||||
|
||||
if ($request->getQueryString()) {
|
||||
$target .= '?' . $request->getQueryString();
|
||||
}
|
||||
|
||||
return redirect()->to($target, 301);
|
||||
}
|
||||
|
||||
return redirect()->route('categories.index', $request->query(), 301);
|
||||
}
|
||||
|
||||
private function resolveCategory(string $groupSlug, string $slugPart, ?string $id): ?Category
|
||||
{
|
||||
if ($id !== null && ctype_digit((string) $id)) {
|
||||
$category = Category::query()
|
||||
->with('contentType')
|
||||
->find((int) $id);
|
||||
|
||||
if ($category) {
|
||||
return $category;
|
||||
}
|
||||
}
|
||||
|
||||
if ($slugPart !== '') {
|
||||
$category = Category::query()
|
||||
->with('contentType')
|
||||
->where('slug', $slugPart)
|
||||
->whereHas('parent', fn ($query) => $query->where('slug', $groupSlug))
|
||||
->first();
|
||||
|
||||
if ($category) {
|
||||
return $category;
|
||||
}
|
||||
}
|
||||
|
||||
return Category::query()
|
||||
->with('contentType')
|
||||
->where('slug', $groupSlug)
|
||||
->whereNull('parent_id')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -3,48 +3,54 @@
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\LegacyService;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MembersController extends Controller
|
||||
{
|
||||
protected LegacyService $legacy;
|
||||
protected ArtworkService $artworks;
|
||||
|
||||
public function __construct(LegacyService $legacy)
|
||||
public function __construct(ArtworkService $artworks)
|
||||
{
|
||||
$this->legacy = $legacy;
|
||||
$this->artworks = $artworks;
|
||||
}
|
||||
|
||||
public function photos(Request $request, $id = null)
|
||||
{
|
||||
$id = (int) ($id ?: 545);
|
||||
$artworks = $this->artworks->getArtworksByContentType('photography', 40);
|
||||
|
||||
$result = $this->legacy->categoryPage('', null, $id);
|
||||
if (! $result) {
|
||||
return redirect('/');
|
||||
}
|
||||
$artworks->getCollection()->load([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
]);
|
||||
|
||||
// categoryPage returns an array with keys used by legacy.browse
|
||||
$page_title = $result['page_title'] ?? ($result['category']->category_name ?? 'Members Photos');
|
||||
$artworks = $result['artworks'] ?? collect();
|
||||
$artworks->getCollection()->transform(function (Artwork $artwork) {
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
|
||||
|
||||
// Ensure artworks include `slug`, `thumb`, and `thumb_srcset` properties expected by the legacy view
|
||||
if ($artworks && method_exists($artworks, 'getCollection')) {
|
||||
$artworks->getCollection()->transform(function ($row) {
|
||||
$row->slug = $row->slug ?? Str::slug($row->name ?? '');
|
||||
$row->thumb = $row->thumb ?? ($row->thumb_url ?? null);
|
||||
$row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null);
|
||||
return $row;
|
||||
});
|
||||
} elseif (is_iterable($artworks)) {
|
||||
$artworks = collect($artworks)->map(function ($row) {
|
||||
$row->slug = $row->slug ?? Str::slug($row->name ?? '');
|
||||
$row->thumb = $row->thumb ?? ($row->thumb_url ?? null);
|
||||
$row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null);
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
return (object) [
|
||||
'id' => $artwork->id,
|
||||
'name' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
'username' => $artwork->user->username ?? '',
|
||||
'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($artwork->user->id ?? 0), $artwork->user->profile->avatar_hash ?? null, 64),
|
||||
'content_type_name' => $primaryCategory?->contentType?->name ?? 'Photography',
|
||||
'content_type_slug' => $primaryCategory?->contentType?->slug ?? 'photography',
|
||||
'category_name' => $primaryCategory?->name ?? '',
|
||||
'category_slug' => $primaryCategory?->slug ?? '',
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'published_at' => $artwork->published_at,
|
||||
];
|
||||
});
|
||||
|
||||
$page_title = 'Member Photos';
|
||||
|
||||
return view('legacy::browse', compact('page_title', 'artworks'));
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class MyBuddiesController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$perPage = 50;
|
||||
|
||||
try {
|
||||
$query = DB::table('friends_list as t1')
|
||||
->leftJoin('users as t2', 't1.friend_id', '=', 't2.id')
|
||||
->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id')
|
||||
->where('t1.user_id', $user->id)
|
||||
->select('t1.id', 't1.friend_id', 't1.user_id', 't2.name as uname', 'p.avatar_hash as icon', 't1.date_added')
|
||||
->orderByDesc('t1.date_added');
|
||||
|
||||
$buddies = $query->paginate($perPage)->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$buddies = collect();
|
||||
}
|
||||
|
||||
$page_title = ($user->name ?? $user->username ?? 'User') . ': Following List';
|
||||
|
||||
return view('legacy::mybuddies', compact('buddies', 'page_title'));
|
||||
}
|
||||
|
||||
public function destroy(Request $request, $id)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$deleted = DB::table('friends_list')->where('id', $id)->where('user_id', $user->id)->delete();
|
||||
if ($deleted) {
|
||||
$request->session()->flash('status', 'Removed from following list.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$request->session()->flash('error', 'Could not remove buddy.');
|
||||
}
|
||||
|
||||
return redirect()->route('legacy.mybuddies');
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class ReceivedCommentsController extends Controller
|
||||
}
|
||||
|
||||
$hits = 33;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$currentPage = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$base = ArtworkComment::with(['user', 'artwork'])
|
||||
->whereHas('artwork', function ($q) use ($user) {
|
||||
@@ -30,7 +30,7 @@ class ReceivedCommentsController extends Controller
|
||||
|
||||
return view('legacy::received-comments', [
|
||||
'comments' => $comments,
|
||||
'page' => $page,
|
||||
'currentPage' => $currentPage,
|
||||
'hits' => $hits,
|
||||
'total' => $comments->total(),
|
||||
]);
|
||||
|
||||
@@ -8,18 +8,20 @@ use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMNode;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\StoryComment;
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryTag;
|
||||
use App\Models\StoryView;
|
||||
use App\Models\User;
|
||||
use App\Notifications\StoryStatusNotification;
|
||||
use App\Services\SocialService;
|
||||
use App\Services\StoryPublicationService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -88,7 +90,7 @@ class StoryController extends Controller
|
||||
public function show(Request $request, string $slug): View
|
||||
{
|
||||
$story = Story::published()
|
||||
->with(['creator.profile', 'tags'])
|
||||
->with(['creator.profile', 'creator.statistics', 'tags'])
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
|
||||
@@ -127,28 +129,49 @@ class StoryController extends Controller
|
||||
->get(['id', 'title', 'slug']);
|
||||
}
|
||||
|
||||
$discussionComments = collect();
|
||||
if ($story->creator_id !== null && Schema::hasTable('profile_comments')) {
|
||||
$discussionComments = DB::table('profile_comments as pc')
|
||||
->join('users as u', 'u.id', '=', 'pc.author_user_id')
|
||||
->where('pc.profile_user_id', $story->creator_id)
|
||||
->where('pc.is_active', true)
|
||||
->orderByDesc('pc.created_at')
|
||||
->limit(8)
|
||||
->get([
|
||||
'pc.id',
|
||||
'pc.body',
|
||||
'pc.created_at',
|
||||
'u.username as author_username',
|
||||
]);
|
||||
}
|
||||
$social = app(SocialService::class);
|
||||
$initialComments = Schema::hasTable('story_comments')
|
||||
? StoryComment::query()
|
||||
->with(['user.profile', 'approvedReplies'])
|
||||
->where('story_id', $story->id)
|
||||
->where('is_approved', true)
|
||||
->whereNull('parent_id')
|
||||
->whereNull('deleted_at')
|
||||
->latest('created_at')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn (StoryComment $comment) => $social->formatComment($comment, $request->user()?->id, true))
|
||||
->values()
|
||||
->all()
|
||||
: [];
|
||||
|
||||
$storyState = $social->storyStateFor($request->user(), $story);
|
||||
|
||||
$storySocialProps = [
|
||||
'story' => [
|
||||
'id' => (int) $story->id,
|
||||
'slug' => (string) $story->slug,
|
||||
'title' => (string) $story->title,
|
||||
],
|
||||
'creator' => $story->creator ? [
|
||||
'id' => (int) $story->creator->id,
|
||||
'username' => (string) ($story->creator->username ?? ''),
|
||||
'display_name' => (string) ($story->creator->name ?: $story->creator->username ?: 'Creator'),
|
||||
'avatar_url' => AvatarUrl::forUser((int) $story->creator->id, $story->creator->profile?->avatar_hash, 128),
|
||||
'followers_count' => (int) ($story->creator->statistics?->followers_count ?? 0),
|
||||
'profile_url' => $story->creator->username ? '/@' . $story->creator->username : null,
|
||||
] : null,
|
||||
'state' => $storyState,
|
||||
'comments' => $initialComments,
|
||||
'is_authenticated' => $request->user() !== null,
|
||||
];
|
||||
|
||||
return view('web.stories.show', [
|
||||
'story' => $story,
|
||||
'safeContent' => $storyContentHtml,
|
||||
'relatedStories' => $relatedStories,
|
||||
'relatedArtworks' => $relatedArtworks,
|
||||
'comments' => $discussionComments,
|
||||
'storySocialProps' => $storySocialProps,
|
||||
'page_title' => $story->title . ' - Skinbase Stories',
|
||||
'page_meta_description' => $story->excerpt ?: Str::limit(strip_tags((string) $story->content), 160),
|
||||
'page_canonical' => route('stories.show', $story->slug),
|
||||
@@ -212,6 +235,10 @@ class StoryController extends Controller
|
||||
|
||||
$story->tags()->sync($this->resolveTagIds($validated));
|
||||
|
||||
if ($resolved['status'] === 'published') {
|
||||
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
|
||||
}
|
||||
|
||||
if ($resolved['status'] === 'published') {
|
||||
return redirect()->route('stories.show', ['slug' => $story->slug])
|
||||
->with('status', 'Story published.');
|
||||
@@ -275,6 +302,8 @@ class StoryController extends Controller
|
||||
{
|
||||
abort_unless($this->canManageStory($request, $story), 403);
|
||||
|
||||
$wasPublished = $story->published_at !== null || $story->status === 'published';
|
||||
|
||||
$validated = $this->validateStoryPayload($request);
|
||||
$resolved = $this->resolveWorkflowState($request, $validated, false);
|
||||
$serializedContent = $this->normalizeStoryContent($validated['content'] ?? []);
|
||||
@@ -302,6 +331,10 @@ class StoryController extends Controller
|
||||
|
||||
$story->tags()->sync($this->resolveTagIds($validated));
|
||||
|
||||
if (! $wasPublished && $resolved['status'] === 'published') {
|
||||
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
|
||||
}
|
||||
|
||||
return back()->with('status', 'Story updated.');
|
||||
}
|
||||
|
||||
@@ -370,14 +403,10 @@ class StoryController extends Controller
|
||||
{
|
||||
abort_unless($this->canManageStory($request, $story), 403);
|
||||
|
||||
$story->update([
|
||||
'status' => 'published',
|
||||
app(StoryPublicationService::class)->publish($story, 'published', [
|
||||
'published_at' => now(),
|
||||
'scheduled_for' => null,
|
||||
]);
|
||||
|
||||
$story->creator?->notify(new StoryStatusNotification($story, 'published'));
|
||||
|
||||
return redirect()->route('stories.show', ['slug' => $story->slug])->with('status', 'Story published.');
|
||||
}
|
||||
|
||||
@@ -512,11 +541,19 @@ class StoryController extends Controller
|
||||
$story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']]));
|
||||
}
|
||||
|
||||
if ($workflow['status'] === 'published') {
|
||||
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'story_id' => (int) $story->id,
|
||||
'status' => $story->status,
|
||||
'message' => 'Story created.',
|
||||
'edit_url' => route('creator.stories.edit', ['story' => $story->id]),
|
||||
'preview_url' => route('creator.stories.preview', ['story' => $story->id]),
|
||||
'analytics_url' => route('creator.stories.analytics', ['story' => $story->id]),
|
||||
'public_url' => route('stories.show', ['slug' => $story->slug]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -540,6 +577,7 @@ class StoryController extends Controller
|
||||
|
||||
$story = Story::query()->findOrFail((int) $validated['story_id']);
|
||||
abort_unless($this->canManageStory($request, $story), 403);
|
||||
$wasPublished = $story->published_at !== null || $story->status === 'published';
|
||||
|
||||
$workflow = $this->resolveWorkflowState($request, array_merge([
|
||||
'status' => $story->status,
|
||||
@@ -576,11 +614,19 @@ class StoryController extends Controller
|
||||
$story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']]));
|
||||
}
|
||||
|
||||
if (! $wasPublished && $workflow['status'] === 'published') {
|
||||
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'story_id' => (int) $story->id,
|
||||
'status' => $story->status,
|
||||
'message' => 'Story updated.',
|
||||
'edit_url' => route('creator.stories.edit', ['story' => $story->id]),
|
||||
'preview_url' => route('creator.stories.preview', ['story' => $story->id]),
|
||||
'analytics_url' => route('creator.stories.analytics', ['story' => $story->id]),
|
||||
'public_url' => route('stories.show', ['slug' => $story->slug]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -631,6 +677,7 @@ class StoryController extends Controller
|
||||
'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null),
|
||||
]);
|
||||
} else {
|
||||
$wasPublished = $story->published_at !== null || $story->status === 'published';
|
||||
$nextContent = array_key_exists('content', $validated)
|
||||
? $this->normalizeStoryContent($validated['content'])
|
||||
: (string) $story->content;
|
||||
@@ -655,6 +702,14 @@ class StoryController extends Controller
|
||||
'scheduled_for' => ! empty($validated['scheduled_for']) ? now()->parse((string) $validated['scheduled_for']) : $story->scheduled_for,
|
||||
]);
|
||||
$story->save();
|
||||
|
||||
if (! $wasPublished && $story->status === 'published') {
|
||||
if ($story->published_at === null) {
|
||||
$story->forceFill(['published_at' => now()])->save();
|
||||
}
|
||||
|
||||
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($validated['tags_csv'])) {
|
||||
@@ -666,6 +721,10 @@ class StoryController extends Controller
|
||||
'story_id' => (int) $story->id,
|
||||
'saved_at' => now()->toIso8601String(),
|
||||
'message' => 'Saved just now',
|
||||
'edit_url' => route('creator.stories.edit', ['story' => $story->id]),
|
||||
'preview_url' => route('creator.stories.preview', ['story' => $story->id]),
|
||||
'analytics_url' => route('creator.stories.analytics', ['story' => $story->id]),
|
||||
'public_url' => route('stories.show', ['slug' => $story->slug]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1047,7 +1106,7 @@ class StoryController extends Controller
|
||||
'orderedList' => '<ol>' . $inner . '</ol>',
|
||||
'listItem' => '<li>' . $inner . '</li>',
|
||||
'horizontalRule' => '<hr>',
|
||||
'codeBlock' => '<pre><code>' . e($this->extractTipTapText($node)) . '</code></pre>',
|
||||
'codeBlock' => $this->renderCodeBlockNode($attrs, $node),
|
||||
'image' => $this->renderImageNode($attrs),
|
||||
'artworkEmbed' => $this->renderArtworkEmbedNode($attrs),
|
||||
'galleryBlock' => $this->renderGalleryBlockNode($attrs),
|
||||
@@ -1057,6 +1116,23 @@ class StoryController extends Controller
|
||||
};
|
||||
}
|
||||
|
||||
private function renderCodeBlockNode(array $attrs, array $node): string
|
||||
{
|
||||
$language = strtolower(trim((string) ($attrs['language'] ?? '')));
|
||||
$language = preg_match('/^[a-z0-9_+-]+$/', $language) === 1 ? $language : '';
|
||||
$escapedCode = e($this->extractTipTapText($node));
|
||||
|
||||
$preAttributes = $language !== ''
|
||||
? ' data-language="' . e($language) . '"'
|
||||
: '';
|
||||
|
||||
$codeAttributes = $language !== ''
|
||||
? ' class="language-' . e($language) . '" data-language="' . e($language) . '"'
|
||||
: '';
|
||||
|
||||
return '<pre' . $preAttributes . '><code' . $codeAttributes . '>' . $escapedCode . '</code></pre>';
|
||||
}
|
||||
|
||||
private function renderImageNode(array $attrs): string
|
||||
{
|
||||
$src = (string) ($attrs['src'] ?? '');
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BuddiesController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$perPage = 50;
|
||||
|
||||
try {
|
||||
$query = DB::table('friends_list as t1')
|
||||
->leftJoin('users as t2', 't1.user_id', '=', 't2.id')
|
||||
->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id')
|
||||
->where('t1.friend_id', $user->id)
|
||||
->select('t1.id', 't1.user_id', 't1.friend_id', 't2.name as uname', 'p.avatar_hash as icon', 't1.date_added')
|
||||
->orderByDesc('t1.date_added');
|
||||
|
||||
$followers = $query->paginate($perPage)->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$followers = collect();
|
||||
}
|
||||
|
||||
$page_title = ($user->name ?? $user->username ?? 'User') . ': Followers';
|
||||
|
||||
return view('user.buddies', compact('followers', 'page_title'));
|
||||
}
|
||||
}
|
||||
@@ -3,68 +3,25 @@
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\UserStatsService;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\ArtworkFavourite;
|
||||
|
||||
class FavouritesController extends Controller
|
||||
{
|
||||
public function index(Request $request, $userId = null, $username = null)
|
||||
{
|
||||
$userId = $userId ? (int) $userId : ($request->user()->id ?? null);
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$hits = 20;
|
||||
$start = ($page - 1) * $hits;
|
||||
|
||||
$total = 0;
|
||||
$results = collect();
|
||||
|
||||
try {
|
||||
$query = ArtworkFavourite::with(['artwork.user'])
|
||||
->where('user_id', $userId)
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('artwork_id');
|
||||
|
||||
$total = (int) $query->count();
|
||||
|
||||
$favorites = $query->skip($start)->take($hits)->get();
|
||||
|
||||
$results = $favorites->map(function ($fav) {
|
||||
$art = $fav->artwork;
|
||||
if (! $art) {
|
||||
return null;
|
||||
}
|
||||
$item = (object) $art->toArray();
|
||||
$item->uname = $art->user?->username ?? $art->user?->name ?? null;
|
||||
$item->datum = $fav->created_at;
|
||||
return $item;
|
||||
})->filter();
|
||||
} catch (\Throwable $e) {
|
||||
$total = 0;
|
||||
$results = collect();
|
||||
$user = $this->resolveLegacyFavouritesUser($request, $userId, $username);
|
||||
if (! $user) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$results = collect($results)->filter()->values()->transform(function ($row) {
|
||||
$row->name = $row->name ?? $row->title ?? '';
|
||||
$row->slug = $row->slug ?? Str::slug($row->name);
|
||||
$row->encoded = isset($row->id) ? app(\App\Helpers\Thumb::class)::encodeId((int) $row->id) : null;
|
||||
return $row;
|
||||
});
|
||||
|
||||
$displayName = $username ?: (DB::table('users')->where('id', $userId)->value('username') ?? '');
|
||||
$page_title = $displayName . ' Favourites';
|
||||
|
||||
return view('user.favourites', [
|
||||
'results' => $results,
|
||||
'page_title' => $page_title,
|
||||
'user_id' => $userId,
|
||||
'page' => $page,
|
||||
'hits' => $hits,
|
||||
'total' => $total,
|
||||
]);
|
||||
return redirect()->route('profile.show', [
|
||||
'username' => strtolower((string) $user->username),
|
||||
'tab' => 'favourites',
|
||||
], 301);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, $userId, $artworkId)
|
||||
@@ -82,6 +39,31 @@ class FavouritesController extends Controller
|
||||
app(UserStatsService::class)->decrementFavoritesReceived($creatorId);
|
||||
}
|
||||
|
||||
return redirect()->route('legacy.favourites', ['id' => $userId])->with('status', 'Removed from favourites');
|
||||
$username = strtolower((string) ($auth->username ?? DB::table('users')->where('id', (int) $userId)->value('username') ?? ''));
|
||||
|
||||
return redirect()->route('profile.show', [
|
||||
'username' => $username,
|
||||
'tab' => 'favourites',
|
||||
])->with('status', 'Removed from favourites');
|
||||
}
|
||||
|
||||
private function resolveLegacyFavouritesUser(Request $request, mixed $userId, mixed $username): ?User
|
||||
{
|
||||
if (is_string($userId) && ! is_numeric($userId) && $username === null) {
|
||||
$username = $userId;
|
||||
$userId = null;
|
||||
}
|
||||
|
||||
if (is_numeric($userId)) {
|
||||
return User::query()->find((int) $userId);
|
||||
}
|
||||
|
||||
if (is_string($username) && $username !== '') {
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
|
||||
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||
}
|
||||
|
||||
return $request->user();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,46 +3,54 @@
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\LegacyService;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MembersController extends Controller
|
||||
{
|
||||
protected LegacyService $legacy;
|
||||
protected ArtworkService $artworks;
|
||||
|
||||
public function __construct(LegacyService $legacy)
|
||||
public function __construct(ArtworkService $artworks)
|
||||
{
|
||||
$this->legacy = $legacy;
|
||||
$this->artworks = $artworks;
|
||||
}
|
||||
|
||||
public function photos(Request $request, $id = null)
|
||||
{
|
||||
$id = (int) ($id ?: 545);
|
||||
$artworks = $this->artworks->getArtworksByContentType('photography', 40);
|
||||
|
||||
$result = $this->legacy->categoryPage('', null, $id);
|
||||
if (! $result) {
|
||||
return redirect('/');
|
||||
}
|
||||
$artworks->getCollection()->load([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
]);
|
||||
|
||||
$page_title = $result['page_title'] ?? ($result['category']->category_name ?? 'Members Photos');
|
||||
$artworks = $result['artworks'] ?? collect();
|
||||
$artworks->getCollection()->transform(function (Artwork $artwork) {
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
|
||||
|
||||
if ($artworks && method_exists($artworks, 'getCollection')) {
|
||||
$artworks->getCollection()->transform(function ($row) {
|
||||
$row->slug = $row->slug ?? Str::slug($row->name ?? '');
|
||||
$row->thumb = $row->thumb ?? ($row->thumb_url ?? null);
|
||||
$row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null);
|
||||
return $row;
|
||||
});
|
||||
} elseif (is_iterable($artworks)) {
|
||||
$artworks = collect($artworks)->map(function ($row) {
|
||||
$row->slug = $row->slug ?? Str::slug($row->name ?? '');
|
||||
$row->thumb = $row->thumb ?? ($row->thumb_url ?? null);
|
||||
$row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null);
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
return (object) [
|
||||
'id' => $artwork->id,
|
||||
'name' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
'username' => $artwork->user->username ?? '',
|
||||
'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($artwork->user->id ?? 0), $artwork->user->profile->avatar_hash ?? null, 64),
|
||||
'content_type_name' => $primaryCategory?->contentType?->name ?? 'Photography',
|
||||
'content_type_slug' => $primaryCategory?->contentType?->slug ?? 'photography',
|
||||
'category_name' => $primaryCategory?->name ?? '',
|
||||
'category_slug' => $primaryCategory?->slug ?? '',
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'published_at' => $artwork->published_at,
|
||||
];
|
||||
});
|
||||
|
||||
$page_title = 'Member Photos';
|
||||
|
||||
return view('web.members.photos', compact('page_title', 'artworks'));
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MyBuddiesController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$perPage = 50;
|
||||
|
||||
try {
|
||||
$query = DB::table('friends_list as t1')
|
||||
->leftJoin('users as t2', 't1.friend_id', '=', 't2.id')
|
||||
->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id')
|
||||
->where('t1.user_id', $user->id)
|
||||
->select('t1.id', 't1.friend_id', 't1.user_id', 't2.name as uname', 't2.username as user_username', 'p.avatar_hash as icon', 't1.date_added')
|
||||
->orderByDesc('t1.date_added');
|
||||
|
||||
$buddies = $query->paginate($perPage)->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$buddies = collect();
|
||||
}
|
||||
|
||||
$page_title = ($user->name ?? $user->username ?? 'User') . ': Following List';
|
||||
|
||||
return view('user.mybuddies', compact('buddies', 'page_title'));
|
||||
}
|
||||
|
||||
public function destroy(Request $request, $id)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$deleted = DB::table('friends_list')->where('id', $id)->where('user_id', $user->id)->delete();
|
||||
if ($deleted) {
|
||||
$request->session()->flash('status', 'Removed from following list.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$request->session()->flash('error', 'Could not remove buddy.');
|
||||
}
|
||||
|
||||
return redirect()->route('legacy.mybuddies');
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,21 @@ use App\Http\Requests\Settings\VerifyEmailChangeRequest;
|
||||
use App\Mail\EmailChangedSecurityAlertMail;
|
||||
use App\Mail\EmailChangeVerificationCodeMail;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Country;
|
||||
use App\Models\ProfileComment;
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonInterface;
|
||||
use App\Services\Security\CaptchaVerifier;
|
||||
use App\Services\AvatarService;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\FollowService;
|
||||
use App\Services\AchievementService;
|
||||
use App\Services\LeaderboardService;
|
||||
use App\Services\Countries\CountryCatalogService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\ThumbnailService;
|
||||
use App\Services\XPService;
|
||||
use App\Services\UsernameApprovalService;
|
||||
use App\Services\UserStatsService;
|
||||
use App\Support\AvatarUrl;
|
||||
@@ -49,6 +55,10 @@ class ProfileController extends Controller
|
||||
private readonly FollowService $followService,
|
||||
private readonly UserStatsService $userStats,
|
||||
private readonly CaptchaVerifier $captchaVerifier,
|
||||
private readonly XPService $xp,
|
||||
private readonly AchievementService $achievements,
|
||||
private readonly LeaderboardService $leaderboards,
|
||||
private readonly CountryCatalogService $countryCatalog,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -74,7 +84,31 @@ class ProfileController extends Controller
|
||||
return redirect()->route('profile.show', ['username' => strtolower((string) $user->username)], 301);
|
||||
}
|
||||
|
||||
return $this->renderUserProfile($request, $user);
|
||||
return $this->renderProfilePage($request, $user);
|
||||
}
|
||||
|
||||
public function showGalleryByUsername(Request $request, string $username)
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
$user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||
|
||||
if (! $user) {
|
||||
$redirect = DB::table('username_redirects')
|
||||
->whereRaw('LOWER(old_username) = ?', [$normalized])
|
||||
->value('new_username');
|
||||
|
||||
if ($redirect) {
|
||||
return redirect()->route('profile.gallery', ['username' => strtolower((string) $redirect)], 301);
|
||||
}
|
||||
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($username !== strtolower((string) $user->username)) {
|
||||
return redirect()->route('profile.gallery', ['username' => strtolower((string) $user->username)], 301);
|
||||
}
|
||||
|
||||
return $this->renderProfilePage($request, $user, 'Profile/ProfileGallery', true);
|
||||
}
|
||||
|
||||
public function legacyById(Request $request, int $id, ?string $username = null)
|
||||
@@ -119,20 +153,27 @@ class ProfileController extends Controller
|
||||
'body' => ['required', 'string', 'min:2', 'max:2000'],
|
||||
]);
|
||||
|
||||
ProfileComment::create([
|
||||
$comment = ProfileComment::create([
|
||||
'profile_user_id' => $target->id,
|
||||
'author_user_id' => Auth::id(),
|
||||
'body' => $request->input('body'),
|
||||
]);
|
||||
|
||||
app(XPService::class)->awardCommentCreated((int) Auth::id(), (int) $comment->id, 'profile');
|
||||
|
||||
return Redirect::route('profile.show', ['username' => strtolower((string) $target->username)])
|
||||
->with('status', 'Comment posted!');
|
||||
}
|
||||
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
$user = $request->user()->loadMissing(['profile', 'country']);
|
||||
$selectedCountry = $this->countryCatalog->resolveUserCountry($user);
|
||||
|
||||
return view('profile.edit', [
|
||||
'user' => $request->user(),
|
||||
'user' => $user,
|
||||
'countries' => $this->countryCatalog->profileSelectOptions(),
|
||||
'selectedCountryId' => $selectedCountry?->id,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -141,7 +182,7 @@ class ProfileController extends Controller
|
||||
*/
|
||||
public function editSettings(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$user = $request->user()->loadMissing(['profile', 'country']);
|
||||
$cooldownDays = $this->usernameCooldownDays();
|
||||
$lastUsernameChangeAt = $this->lastUsernameChangeAt($user);
|
||||
$usernameCooldownRemainingDays = 0;
|
||||
@@ -188,15 +229,8 @@ class ProfileController extends Controller
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
// Country list
|
||||
$countries = collect();
|
||||
try {
|
||||
if (Schema::hasTable('country_list')) {
|
||||
$countries = DB::table('country_list')->orderBy('country_name')->get();
|
||||
} elseif (Schema::hasTable('countries')) {
|
||||
$countries = DB::table('countries')->orderBy('name')->get();
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
$selectedCountry = $this->countryCatalog->resolveUserCountry($user);
|
||||
$countries = $this->countryCatalog->profileSelectOptions();
|
||||
|
||||
// Avatar URL
|
||||
$avatarHash = $profileData['avatar_hash'] ?? $user->icon ?? null;
|
||||
@@ -222,7 +256,8 @@ class ProfileController extends Controller
|
||||
'description' => $user->description ?? null,
|
||||
'gender' => $user->gender ?? null,
|
||||
'birthday' => $user->birth ?? null,
|
||||
'country_code' => $user->country_code ?? null,
|
||||
'country_id' => $selectedCountry?->id ?? $user->country_id ?? null,
|
||||
'country_code' => $selectedCountry?->iso2 ?? $user->country_code ?? null,
|
||||
'email_notifications' => $emailNotifications,
|
||||
'upload_notifications' => $uploadNotifications,
|
||||
'follower_notifications' => $followerNotifications,
|
||||
@@ -238,7 +273,7 @@ class ProfileController extends Controller
|
||||
'usernameCooldownDays' => $cooldownDays,
|
||||
'usernameCooldownRemainingDays' => $usernameCooldownRemainingDays,
|
||||
'usernameCooldownActive' => $usernameCooldownRemainingDays > 0,
|
||||
'countries' => $countries->values(),
|
||||
'countries' => $countries,
|
||||
'flash' => [
|
||||
'status' => session('status'),
|
||||
'error' => session('error'),
|
||||
@@ -434,10 +469,12 @@ class ProfileController extends Controller
|
||||
public function updatePersonalSection(UpdatePersonalSectionRequest $request): RedirectResponse|JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$selectedCountry = $this->resolveCountrySelection($validated['country_id'] ?? null);
|
||||
$this->persistUserCountrySelection($request->user(), $selectedCountry);
|
||||
|
||||
$profileUpdates = [
|
||||
'birthdate' => $validated['birthday'] ?? null,
|
||||
'country_code' => $validated['country'] ?? null,
|
||||
'country_code' => $selectedCountry?->iso2,
|
||||
];
|
||||
|
||||
if (!empty($validated['gender'])) {
|
||||
@@ -513,6 +550,29 @@ class ProfileController extends Controller
|
||||
DB::table('user_profiles')->updateOrInsert(['user_id' => $userId], $filtered);
|
||||
}
|
||||
|
||||
private function resolveCountrySelection(int|string|null $countryId = null, ?string $countryCode = null): ?Country
|
||||
{
|
||||
if (is_numeric($countryId) && (int) $countryId > 0) {
|
||||
return $this->countryCatalog->findById((int) $countryId);
|
||||
}
|
||||
|
||||
if ($countryCode !== null && trim($countryCode) !== '') {
|
||||
return $this->countryCatalog->findByIso2($countryCode);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function persistUserCountrySelection(User $user, ?Country $country): void
|
||||
{
|
||||
if (! Schema::hasColumn('users', 'country_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user->country_id = $country?->id;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
private function usernameCooldownDays(): int
|
||||
{
|
||||
return max(1, (int) config('usernames.rename_cooldown_days', 30));
|
||||
@@ -655,7 +715,15 @@ class ProfileController extends Controller
|
||||
$profileUpdates['gender'] = $map[$g] ?? strtoupper($validated['gender']);
|
||||
}
|
||||
|
||||
if (!empty($validated['country'])) $profileUpdates['country_code'] = $validated['country'];
|
||||
if (array_key_exists('country_id', $validated) || array_key_exists('country', $validated)) {
|
||||
$selectedCountry = $this->resolveCountrySelection(
|
||||
$validated['country_id'] ?? null,
|
||||
$validated['country'] ?? null,
|
||||
);
|
||||
|
||||
$this->persistUserCountrySelection($user, $selectedCountry);
|
||||
$profileUpdates['country_code'] = $selectedCountry?->iso2;
|
||||
}
|
||||
|
||||
if (array_key_exists('mailing', $validated)) {
|
||||
$profileUpdates['mlist'] = filter_var($validated['mailing'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
|
||||
@@ -768,7 +836,7 @@ class ProfileController extends Controller
|
||||
return Redirect::route('dashboard.profile')->with('status', 'password-updated');
|
||||
}
|
||||
|
||||
private function renderUserProfile(Request $request, User $user)
|
||||
private function renderProfilePage(Request $request, User $user, string $component = 'Profile/ProfileShow', bool $galleryOnly = false)
|
||||
{
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
$viewer = Auth::user();
|
||||
@@ -777,21 +845,7 @@ class ProfileController extends Controller
|
||||
// ── Artworks (cursor-paginated) ──────────────────────────────────────
|
||||
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage)
|
||||
->through(function (Artwork $art) {
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
return (object) [
|
||||
'id' => $art->id,
|
||||
'name' => $art->title,
|
||||
'picture' => $art->file_name,
|
||||
'datum' => $art->published_at,
|
||||
'published_at' => $art->published_at, // required by cursor paginator (orders by this column)
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $art->user->name ?? 'Skinbase',
|
||||
'username' => $art->user->username ?? null,
|
||||
'user_id' => $art->user_id,
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
];
|
||||
return (object) $this->mapArtworkCardPayload($art);
|
||||
});
|
||||
|
||||
// ── Featured artworks for this user ─────────────────────────────────
|
||||
@@ -829,27 +883,42 @@ class ProfileController extends Controller
|
||||
}
|
||||
|
||||
// ── Favourites ───────────────────────────────────────────────────────
|
||||
$favourites = collect();
|
||||
if (Schema::hasTable('user_favorites')) {
|
||||
$favIds = DB::table('user_favorites as uf')
|
||||
->join('artworks as a', 'a.id', '=', 'uf.artwork_id')
|
||||
->where('uf.user_id', $user->id)
|
||||
$favouriteLimit = 12;
|
||||
$favouriteTable = $this->resolveFavouriteTable();
|
||||
$favourites = [
|
||||
'data' => [],
|
||||
'next_cursor' => null,
|
||||
];
|
||||
if ($favouriteTable !== null) {
|
||||
$favIds = DB::table($favouriteTable . ' as af')
|
||||
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
|
||||
->where('af.user_id', $user->id)
|
||||
->whereNull('a.deleted_at')
|
||||
->where('a.is_public', true)
|
||||
->where('a.is_approved', true)
|
||||
->orderByDesc('uf.created_at')
|
||||
->limit(12)
|
||||
->whereNotNull('a.published_at')
|
||||
->orderByDesc('af.created_at')
|
||||
->orderByDesc('af.artwork_id')
|
||||
->limit($favouriteLimit + 1)
|
||||
->pluck('a.id');
|
||||
|
||||
if ($favIds->isNotEmpty()) {
|
||||
$hasMore = $favIds->count() > $favouriteLimit;
|
||||
$favIds = $favIds->take($favouriteLimit);
|
||||
|
||||
$indexed = Artwork::with('user:id,name,username')
|
||||
->whereIn('id', $favIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
// Preserve the ordering from the favourites table
|
||||
$favourites = $favIds
|
||||
|
||||
$favourites = [
|
||||
'data' => $favIds
|
||||
->filter(fn ($id) => $indexed->has($id))
|
||||
->map(fn ($id) => $indexed[$id]);
|
||||
->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id]))
|
||||
->values()
|
||||
->all(),
|
||||
'next_cursor' => $hasMore ? base64_encode((string) $favouriteLimit) : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -916,6 +985,7 @@ class ProfileController extends Controller
|
||||
->select([
|
||||
'pc.id', 'pc.body', 'pc.created_at',
|
||||
'u.id as author_id', 'u.username as author_username', 'u.name as author_name',
|
||||
'u.level as author_level', 'u.rank as author_rank',
|
||||
'up.avatar_hash as author_avatar_hash', 'up.signature as author_signature',
|
||||
])
|
||||
->get()
|
||||
@@ -925,12 +995,16 @@ class ProfileController extends Controller
|
||||
'created_at' => $row->created_at,
|
||||
'author_id' => $row->author_id,
|
||||
'author_name' => $row->author_username ?? $row->author_name ?? 'Unknown',
|
||||
'author_level' => (int) ($row->author_level ?? 1),
|
||||
'author_rank' => (string) ($row->author_rank ?? 'Newbie'),
|
||||
'author_profile_url' => '/@' . strtolower((string) ($row->author_username ?? $row->author_id)),
|
||||
'author_avatar' => AvatarUrl::forUser((int) $row->author_id, $row->author_avatar_hash, 50),
|
||||
'author_signature' => $row->author_signature,
|
||||
]);
|
||||
}
|
||||
|
||||
$xpSummary = $this->xp->summary((int) $user->id);
|
||||
|
||||
$creatorStories = Story::query()
|
||||
->published()
|
||||
->with(['tags'])
|
||||
@@ -959,21 +1033,19 @@ class ProfileController extends Controller
|
||||
'views' => (int) $story->views,
|
||||
'likes_count' => (int) $story->likes_count,
|
||||
'comments_count' => (int) $story->comments_count,
|
||||
'creator_level' => $xpSummary['level'],
|
||||
'creator_rank' => $xpSummary['rank'],
|
||||
'published_at' => $story->published_at?->toISOString(),
|
||||
]);
|
||||
|
||||
// ── Profile data ─────────────────────────────────────────────────────
|
||||
$profile = $user->profile;
|
||||
$country = $this->countryCatalog->resolveUserCountry($user);
|
||||
$countryCode = $country?->iso2 ?? $profile?->country_code;
|
||||
$countryName = $country?->name_common;
|
||||
|
||||
// ── Country name (from old country_list table if available) ──────────
|
||||
$countryName = null;
|
||||
if ($profile?->country_code) {
|
||||
if (Schema::hasTable('country_list')) {
|
||||
$countryName = DB::table('country_list')
|
||||
->where('country_code', $profile->country_code)
|
||||
->value('country_name');
|
||||
}
|
||||
$countryName = $countryName ?? strtoupper((string) $profile->country_code);
|
||||
if ($countryName === null && $profile?->country_code) {
|
||||
$countryName = strtoupper((string) $profile->country_code);
|
||||
}
|
||||
|
||||
// ── Cover image hero (preferred) ────────────────────────────────────
|
||||
@@ -1013,9 +1085,13 @@ class ProfileController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
$canonical = url('/@' . strtolower((string) ($user->username ?? '')));
|
||||
$usernameSlug = strtolower((string) ($user->username ?? ''));
|
||||
$canonical = url('/@' . $usernameSlug);
|
||||
$galleryUrl = url('/@' . $usernameSlug . '/gallery');
|
||||
$achievementSummary = $this->achievements->summary((int) $user->id);
|
||||
$leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id);
|
||||
|
||||
return Inertia::render('Profile/ProfileShow', [
|
||||
return Inertia::render($component, [
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'username' => $user->username,
|
||||
@@ -1025,18 +1101,25 @@ class ProfileController extends Controller
|
||||
'cover_position'=> (int) ($user->cover_position ?? 50),
|
||||
'created_at' => $user->created_at?->toISOString(),
|
||||
'last_visit_at' => $user->last_visit_at ? (string) $user->last_visit_at : null,
|
||||
'xp' => $xpSummary['xp'],
|
||||
'level' => $xpSummary['level'],
|
||||
'rank' => $xpSummary['rank'],
|
||||
'next_level_xp' => $xpSummary['next_level_xp'],
|
||||
'current_level_xp' => $xpSummary['current_level_xp'],
|
||||
'progress_percent' => $xpSummary['progress_percent'],
|
||||
'max_level' => $xpSummary['max_level'],
|
||||
],
|
||||
'profile' => $profile ? [
|
||||
'about' => $profile->about ?? null,
|
||||
'website' => $profile->website ?? null,
|
||||
'country_code' => $profile->country_code ?? null,
|
||||
'country_code' => $countryCode,
|
||||
'gender' => $profile->gender ?? null,
|
||||
'birthdate' => $profile->birthdate ?? null,
|
||||
'cover_image' => $profile->cover_image ?? null,
|
||||
] : null,
|
||||
'artworks' => $artworkPayload,
|
||||
'featuredArtworks' => $featuredArtworks->values(),
|
||||
'favourites' => $favourites->values(),
|
||||
'favourites' => $favourites,
|
||||
'stats' => $stats,
|
||||
'socialLinks' => $socialLinks,
|
||||
'followerCount' => $followerCount,
|
||||
@@ -1045,14 +1128,71 @@ class ProfileController extends Controller
|
||||
'heroBgUrl' => $heroBgUrl,
|
||||
'profileComments' => $profileComments->values(),
|
||||
'creatorStories' => $creatorStories->values(),
|
||||
'achievements' => $achievementSummary,
|
||||
'leaderboardRank' => $leaderboardRank,
|
||||
'countryName' => $countryName,
|
||||
'isOwner' => $isOwner,
|
||||
'auth' => $authData,
|
||||
'profileUrl' => $canonical,
|
||||
'galleryUrl' => $galleryUrl,
|
||||
])->withViewData([
|
||||
'page_title' => ($user->username ?? $user->name ?? 'User') . ' on Skinbase',
|
||||
'page_canonical' => $canonical,
|
||||
'page_meta_description' => 'View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.',
|
||||
'page_title' => $galleryOnly
|
||||
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
|
||||
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase'),
|
||||
'page_canonical' => $galleryOnly ? $galleryUrl : $canonical,
|
||||
'page_meta_description' => $galleryOnly
|
||||
? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.')
|
||||
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.'),
|
||||
'og_image' => $avatarUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveFavouriteTable(): ?string
|
||||
{
|
||||
foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) {
|
||||
if (Schema::hasTable($table)) {
|
||||
return $table;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapArtworkCardPayload(Artwork $art): array
|
||||
{
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
|
||||
return [
|
||||
'id' => $art->id,
|
||||
'name' => $art->title,
|
||||
'picture' => $art->file_name,
|
||||
'datum' => $this->formatIsoDate($art->published_at),
|
||||
'published_at' => $this->formatIsoDate($art->published_at),
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $art->user->name ?? 'Skinbase',
|
||||
'username' => $art->user->username ?? null,
|
||||
'user_id' => $art->user_id,
|
||||
'author_level' => (int) ($art->user?->level ?? 1),
|
||||
'author_rank' => (string) ($art->user?->rank ?? 'Newbie'),
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
];
|
||||
}
|
||||
|
||||
private function formatIsoDate(mixed $value): ?string
|
||||
{
|
||||
if ($value instanceof CarbonInterface) {
|
||||
return $value->toISOString();
|
||||
}
|
||||
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format(DATE_ATOM);
|
||||
}
|
||||
|
||||
return is_string($value) ? $value : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Http\Controllers\User;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\ArtworkDownload;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
|
||||
@@ -17,7 +16,11 @@ class TodayDownloadsController extends Controller
|
||||
|
||||
$today = Carbon::now()->toDateString();
|
||||
|
||||
$query = ArtworkDownload::with(['artwork'])
|
||||
$query = ArtworkDownload::with([
|
||||
'artwork.user:id,name,username',
|
||||
'artwork.user.profile:user_id,avatar_hash',
|
||||
'artwork.categories:id,name,slug',
|
||||
])
|
||||
->whereDate('created_at', $today)
|
||||
->whereHas('artwork', function ($q) {
|
||||
$q->public()->published()->whereNull('deleted_at');
|
||||
@@ -34,13 +37,31 @@ class TodayDownloadsController extends Controller
|
||||
$art = \App\Models\Artwork::find($row->artwork_id);
|
||||
}
|
||||
|
||||
if (! $art) {
|
||||
return (object) [
|
||||
'id' => null,
|
||||
'name' => 'Artwork',
|
||||
'slug' => 'artwork',
|
||||
'thumb' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'thumb_url' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'thumb_srcset' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'category_name' => '',
|
||||
'category_slug' => '',
|
||||
'num_downloads' => $row->num_downloads ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
$name = $art->title ?? null;
|
||||
$picture = $art->file_name ?? null;
|
||||
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$encoded = null;
|
||||
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$categoryId = $art->categories->first()->id ?? null;
|
||||
$primaryCategory = $art->categories->first();
|
||||
$categoryId = $primaryCategory->id ?? null;
|
||||
$categoryName = $primaryCategory->name ?? '';
|
||||
$categorySlug = $primaryCategory->slug ?? '';
|
||||
$avatarHash = $art->user->profile->avatar_hash ?? null;
|
||||
|
||||
return (object) [
|
||||
'id' => $art->id ?? null,
|
||||
@@ -50,8 +71,17 @@ class TodayDownloadsController extends Controller
|
||||
'ext' => $ext,
|
||||
'encoded' => $encoded,
|
||||
'thumb' => $thumb,
|
||||
'thumb_url' => $thumb,
|
||||
'thumb_srcset' => $thumb,
|
||||
'category' => $categoryId,
|
||||
'category_name' => $categoryName,
|
||||
'category_slug' => $categorySlug,
|
||||
'uname' => $art->user->name ?? 'Skinbase',
|
||||
'username' => $art->user->username ?? '',
|
||||
'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($art->user->id ?? 0), $avatarHash, 64),
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
'published_at' => $art->published_at,
|
||||
'num_downloads' => $row->num_downloads ?? 0,
|
||||
'gid_num' => $categoryId ? ((int) $categoryId % 5) * 5 : 0,
|
||||
];
|
||||
|
||||
@@ -264,35 +264,6 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function legacyCategory(Request $request, ?string $group = null, ?string $slug = null, ?string $id = null)
|
||||
{
|
||||
if ($id !== null && ctype_digit((string) $id)) {
|
||||
$category = Category::with('contentType')->find((int) $id);
|
||||
if (! $category || ! $category->contentType) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return redirect($category->url, 301);
|
||||
}
|
||||
|
||||
$contentSlug = strtolower((string) $group);
|
||||
if (! in_array($contentSlug, self::CONTENT_TYPE_SLUGS, true)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$target = '/' . $contentSlug;
|
||||
$normalizedSlug = trim((string) $slug, '/');
|
||||
if ($normalizedSlug !== '') {
|
||||
$target .= '/' . strtolower($normalizedSlug);
|
||||
}
|
||||
|
||||
if ($request->query()) {
|
||||
$target .= '?' . http_build_query($request->query());
|
||||
}
|
||||
|
||||
return redirect($target, 301);
|
||||
}
|
||||
|
||||
private function presentArtwork(Artwork $artwork): object
|
||||
{
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
|
||||
@@ -45,6 +45,10 @@ final class CommunityActivityController extends Controller
|
||||
|
||||
private function resolveFilter(Request $request): string
|
||||
{
|
||||
if ($request->filled('type') && ! $request->filled('filter')) {
|
||||
return (string) $request->query('type', 'all');
|
||||
}
|
||||
|
||||
if ($request->boolean('following') && ! $request->filled('filter')) {
|
||||
return 'following';
|
||||
}
|
||||
|
||||
@@ -4,51 +4,23 @@ namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use App\Support\UsernamePolicy;
|
||||
|
||||
class GalleryController extends Controller
|
||||
{
|
||||
public function show(Request $request, $userId, $username = null)
|
||||
{
|
||||
$user = User::find((int)$userId);
|
||||
$user = User::find((int) $userId);
|
||||
if (! $user) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// canonicalize username in URL when possible
|
||||
try {
|
||||
$correctName = $user->name ?? $user->uname ?? null;
|
||||
if ($username && $correctName && $username !== $correctName) {
|
||||
$qs = $request->getQueryString();
|
||||
$url = route('legacy.gallery', ['id' => $user->id, 'username' => $correctName]);
|
||||
if ($qs) $url .= '?' . $qs;
|
||||
return redirect($url, 301);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
$usernameSlug = UsernamePolicy::normalize((string) ($user->username ?? $user->name ?? ''));
|
||||
if ($usernameSlug === '') {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$hits = 20;
|
||||
|
||||
$query = Artwork::where('user_id', $user->id)
|
||||
->approved()
|
||||
->published()
|
||||
->public()
|
||||
->orderByDesc('published_at');
|
||||
|
||||
$total = (int) $query->count();
|
||||
|
||||
$artworks = $query->skip(($page - 1) * $hits)->take($hits)->get();
|
||||
|
||||
return view('web.gallery', [
|
||||
'user' => $user,
|
||||
'artworks' => $artworks,
|
||||
'page' => $page,
|
||||
'hits' => $hits,
|
||||
'total' => $total,
|
||||
]);
|
||||
return redirect()->route('profile.gallery', ['username' => $usernameSlug], 301);
|
||||
}
|
||||
}
|
||||
|
||||
35
app/Http/Controllers/Web/LeaderboardPageController.php
Normal file
35
app/Http/Controllers/Web/LeaderboardPageController.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Leaderboard;
|
||||
use App\Services\LeaderboardService;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class LeaderboardPageController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, LeaderboardService $leaderboards): Response
|
||||
{
|
||||
$period = $leaderboards->normalizePeriod((string) $request->query('period', 'weekly'));
|
||||
$type = match ((string) $request->query('type', 'creators')) {
|
||||
'artworks', Leaderboard::TYPE_ARTWORK => Leaderboard::TYPE_ARTWORK,
|
||||
'stories', Leaderboard::TYPE_STORY => Leaderboard::TYPE_STORY,
|
||||
default => Leaderboard::TYPE_CREATOR,
|
||||
};
|
||||
|
||||
return Inertia::render('Leaderboard/LeaderboardPage', [
|
||||
'initialType' => $type,
|
||||
'initialPeriod' => $period,
|
||||
'initialData' => $leaderboards->getLeaderboard($type, $period),
|
||||
'meta' => [
|
||||
'title' => 'Top Creators & Artworks Leaderboard | Skinbase',
|
||||
'description' => 'Track the leading creators, artworks, and stories across Skinbase by daily, weekly, monthly, and all-time performance.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ final class HandleInertiaRequests extends Middleware
|
||||
*/
|
||||
public function rootView(Request $request): string
|
||||
{
|
||||
if ($request->path() === 'leaderboard') {
|
||||
return 'leaderboard';
|
||||
}
|
||||
|
||||
if (str_starts_with($request->path(), 'studio')) {
|
||||
return 'studio';
|
||||
}
|
||||
|
||||
106
app/Http/Middleware/RedirectLegacyProfileSubdomain.php
Normal file
106
app/Http/Middleware/RedirectLegacyProfileSubdomain.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,8 @@ class ProfileUpdateRequest extends FormRequest
|
||||
'month' => ['nullable', 'numeric', 'between:1,12'],
|
||||
'year' => ['nullable', 'numeric', 'digits:4'],
|
||||
'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'],
|
||||
'notify' => ['nullable', 'boolean'],
|
||||
'auto_post_upload' => ['nullable', 'boolean'],
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdatePersonalSectionRequest extends FormRequest
|
||||
{
|
||||
@@ -18,7 +19,7 @@ class UpdatePersonalSectionRequest extends FormRequest
|
||||
return [
|
||||
'birthday' => ['nullable', 'date', 'before:today'],
|
||||
'gender' => ['nullable', 'in:m,f,x,M,F,X'],
|
||||
'country' => ['nullable', 'string', 'max:10'],
|
||||
'country_id' => ['nullable', 'integer', Rule::exists('countries', 'id')],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +42,14 @@ class ArtworkResource extends JsonResource
|
||||
$viewerId = (int) optional($request->user())->id;
|
||||
$isLiked = false;
|
||||
$isFavorited = false;
|
||||
$isBookmarked = false;
|
||||
$isFollowing = false;
|
||||
$viewerAward = null;
|
||||
|
||||
$bookmarksCount = Schema::hasTable('artwork_bookmarks')
|
||||
? (int) DB::table('artwork_bookmarks')->where('artwork_id', (int) $this->id)->count()
|
||||
: 0;
|
||||
|
||||
if ($viewerId > 0) {
|
||||
if (Schema::hasTable('artwork_likes')) {
|
||||
$isLiked = DB::table('artwork_likes')
|
||||
@@ -53,6 +58,13 @@ class ArtworkResource extends JsonResource
|
||||
->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')
|
||||
->where('user_id', $viewerId)
|
||||
->where('artwork_id', (int) $this->id)
|
||||
@@ -114,6 +126,7 @@ class ArtworkResource extends JsonResource
|
||||
'followers_count' => $followerCount,
|
||||
],
|
||||
'viewer' => [
|
||||
'is_bookmarked' => $isBookmarked,
|
||||
'is_liked' => $isLiked,
|
||||
'is_favorited' => $isFavorited,
|
||||
'is_following_author' => $isFollowing,
|
||||
@@ -121,6 +134,7 @@ class ArtworkResource extends JsonResource
|
||||
'id' => $viewerId > 0 ? $viewerId : null,
|
||||
],
|
||||
'stats' => [
|
||||
'bookmarks' => $bookmarksCount,
|
||||
'views' => (int) ($this->stats?->views ?? 0),
|
||||
'downloads' => (int) ($this->stats?->downloads ?? 0),
|
||||
'favorites' => (int) ($this->stats?->favorites ?? 0),
|
||||
|
||||
24
app/Jobs/UpdateLeaderboardsJob.php
Normal file
24
app/Jobs/UpdateLeaderboardsJob.php
Normal 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();
|
||||
}
|
||||
}
|
||||
19
app/Listeners/Achievements/CheckUserAchievements.php
Normal file
19
app/Listeners/Achievements/CheckUserAchievements.php
Normal 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);
|
||||
}
|
||||
}
|
||||
18
app/Listeners/Posts/AwardXpForPostCommented.php
Normal file
18
app/Listeners/Posts/AwardXpForPostCommented.php
Normal 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');
|
||||
}
|
||||
}
|
||||
46
app/Models/Achievement.php
Normal file
46
app/Models/Achievement.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* Unified activity feed event.
|
||||
*
|
||||
* Types: upload | comment | favorite | award | follow
|
||||
* target_type: artwork | user
|
||||
* target_type: artwork | story | user
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $actor_id
|
||||
@@ -54,6 +54,7 @@ class ActivityEvent extends Model
|
||||
const TYPE_FOLLOW = 'follow';
|
||||
|
||||
const TARGET_ARTWORK = 'artwork';
|
||||
const TARGET_STORY = 'story';
|
||||
const TARGET_USER = 'user';
|
||||
|
||||
// ── Relations ─────────────────────────────────────────────────────────────
|
||||
|
||||
88
app/Models/Country.php
Normal file
88
app/Models/Country.php
Normal 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';
|
||||
}
|
||||
}
|
||||
91
app/Models/DashboardPreference.php
Normal file
91
app/Models/DashboardPreference.php
Normal 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) : [];
|
||||
}
|
||||
}
|
||||
37
app/Models/Leaderboard.php
Normal file
37
app/Models/Leaderboard.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\StoryLike;
|
||||
use App\Models\StoryBookmark;
|
||||
use App\Models\StoryComment;
|
||||
use App\Models\StoryView;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -101,6 +103,16 @@ class Story extends Model
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
public function scopePublished($query)
|
||||
|
||||
36
app/Models/StoryBookmark.php
Normal file
36
app/Models/StoryBookmark.php
Normal 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');
|
||||
}
|
||||
}
|
||||
62
app/Models/StoryComment.php
Normal file
62
app/Models/StoryComment.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use App\Models\SocialAccount;
|
||||
@@ -12,6 +13,9 @@ use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Achievement;
|
||||
use App\Models\UserAchievement;
|
||||
use App\Models\UserXpLog;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
@@ -50,6 +54,9 @@ class User extends Authenticatable
|
||||
'spam_reports',
|
||||
'approved_posts',
|
||||
'flagged_posts',
|
||||
'xp',
|
||||
'level',
|
||||
'rank',
|
||||
'password',
|
||||
'role',
|
||||
'allow_messages_from',
|
||||
@@ -88,6 +95,9 @@ class User extends Authenticatable
|
||||
'spam_reports' => 'integer',
|
||||
'approved_posts' => 'integer',
|
||||
'flagged_posts' => 'integer',
|
||||
'xp' => 'integer',
|
||||
'level' => 'integer',
|
||||
'rank' => 'string',
|
||||
'password' => 'hashed',
|
||||
'allow_messages_from' => 'string',
|
||||
];
|
||||
@@ -108,6 +118,16 @@ class User extends Authenticatable
|
||||
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
|
||||
{
|
||||
return $this->hasOne(UserStatistic::class, 'user_id');
|
||||
@@ -140,6 +160,22 @@ class User extends Authenticatable
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
public function conversations(): BelongsToMany
|
||||
|
||||
42
app/Models/UserAchievement.php
Normal file
42
app/Models/UserAchievement.php
Normal 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
39
app/Models/UserXpLog.php
Normal 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);
|
||||
}
|
||||
}
|
||||
40
app/Notifications/AchievementUnlockedNotification.php
Normal file
40
app/Notifications/AchievementUnlockedNotification.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Notifications/ArtworkCommentedNotification.php
Normal file
51
app/Notifications/ArtworkCommentedNotification.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
48
app/Notifications/ArtworkLikedNotification.php
Normal file
48
app/Notifications/ArtworkLikedNotification.php
Normal 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]),
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Notifications/ArtworkMentionedNotification.php
Normal file
51
app/Notifications/ArtworkMentionedNotification.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,11 @@ class ArtworkSharedNotification extends Notification implements ShouldQueue
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
public function databaseType(object $notifiable): string
|
||||
{
|
||||
return 'artwork_shared';
|
||||
}
|
||||
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
@@ -34,7 +39,7 @@ class ArtworkSharedNotification extends Notification implements ShouldQueue
|
||||
'sharer_id' => $this->sharer->id,
|
||||
'sharer_name' => $this->sharer->name,
|
||||
'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",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ class PostCommentedNotification extends Notification implements ShouldQueue
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
public function databaseType(object $notifiable): string
|
||||
{
|
||||
return 'post_commented';
|
||||
}
|
||||
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
|
||||
49
app/Notifications/StoryCommentedNotification.php
Normal file
49
app/Notifications/StoryCommentedNotification.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
46
app/Notifications/StoryLikedNotification.php
Normal file
46
app/Notifications/StoryLikedNotification.php
Normal 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]),
|
||||
];
|
||||
}
|
||||
}
|
||||
49
app/Notifications/StoryMentionedNotification.php
Normal file
49
app/Notifications/StoryMentionedNotification.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,13 @@ class StoryStatusNotification extends Notification
|
||||
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
|
||||
{
|
||||
$message = match ($this->event) {
|
||||
@@ -34,7 +41,9 @@ class StoryStatusNotification extends Notification
|
||||
};
|
||||
|
||||
return [
|
||||
'type' => 'story.' . $this->event,
|
||||
'type' => in_array($this->event, ['approved', 'published'], true)
|
||||
? 'story_published'
|
||||
: 'story.' . $this->event,
|
||||
'story_id' => $this->story->id,
|
||||
'title' => $this->story->title,
|
||||
'slug' => $this->story->slug,
|
||||
|
||||
40
app/Notifications/UserFollowedNotification.php
Normal file
40
app/Notifications/UserFollowedNotification.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Observers;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Services\UserStatsService;
|
||||
use App\Services\UserMentionSyncService;
|
||||
use App\Services\XPService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
@@ -18,6 +19,7 @@ class ArtworkCommentObserver
|
||||
public function __construct(
|
||||
private readonly UserStatsService $userStats,
|
||||
private readonly UserMentionSyncService $mentionSync,
|
||||
private readonly XPService $xp,
|
||||
) {}
|
||||
|
||||
public function created(ArtworkComment $comment): void
|
||||
@@ -30,6 +32,7 @@ class ArtworkCommentObserver
|
||||
// The commenter is "active"
|
||||
$this->userStats->ensureRow($comment->user_id);
|
||||
$this->userStats->setLastActiveAt($comment->user_id);
|
||||
$this->xp->awardCommentCreated((int) $comment->user_id, (int) $comment->id, 'artwork');
|
||||
$this->mentionSync->syncForComment($comment);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Events\Achievements\AchievementCheckRequested;
|
||||
use App\Models\Artwork;
|
||||
use App\Jobs\RecComputeSimilarByTagsJob;
|
||||
use App\Jobs\RecComputeSimilarHybridJob;
|
||||
use App\Jobs\Posts\AutoUploadPostJob;
|
||||
use App\Services\ArtworkSearchIndexer;
|
||||
use App\Services\UserStatsService;
|
||||
use App\Services\XPService;
|
||||
|
||||
/**
|
||||
* Syncs artwork documents to Meilisearch on every relevant model event.
|
||||
@@ -22,6 +24,7 @@ class ArtworkObserver
|
||||
public function __construct(
|
||||
private readonly ArtworkSearchIndexer $indexer,
|
||||
private readonly UserStatsService $userStats,
|
||||
private readonly XPService $xp,
|
||||
) {}
|
||||
|
||||
/** New artwork created — index; bump uploadscount + last_upload_at. */
|
||||
@@ -30,6 +33,11 @@ class ArtworkObserver
|
||||
$this->indexer->index($artwork);
|
||||
$this->userStats->incrementUploads($artwork->user_id);
|
||||
$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. */
|
||||
@@ -52,6 +60,9 @@ class ArtworkObserver
|
||||
|
||||
// Auto-upload post: fire only when artwork transitions to published for the first time
|
||||
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;
|
||||
$autoPost = $user?->profile?->auto_post_upload ?? true;
|
||||
if ($autoPost) {
|
||||
|
||||
@@ -24,6 +24,8 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Queue\Events\JobFailed;
|
||||
use App\Services\ReceivedCommentsInboxService;
|
||||
use Klevze\ControlPanel\Framework\Core\Menu;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -32,6 +34,11 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(
|
||||
\App\Services\Countries\CountryRemoteProviderInterface::class,
|
||||
\App\Services\Countries\CountryRemoteProvider::class,
|
||||
);
|
||||
|
||||
// Bind UploadDraftService interface to implementation
|
||||
$this->app->singleton(UploadDraftServiceInterface::class, function ($app) {
|
||||
return new UploadDraftService($app->make('filesystem'));
|
||||
@@ -55,6 +62,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->registerCpadMenuItems();
|
||||
|
||||
// Map the 'legacy' view namespace to resources/views/_legacy so all
|
||||
// view('legacy::foo') and @include('legacy::foo') calls resolve correctly
|
||||
// after the folder was renamed from legacy/ to _legacy/.
|
||||
@@ -66,6 +75,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->configureDownloadRateLimiter();
|
||||
$this->configureArtworkRateLimiters();
|
||||
$this->configureReactionRateLimiters();
|
||||
$this->configureSocialRateLimiters();
|
||||
$this->configureSettingsRateLimiters();
|
||||
$this->configureMailFailureLogging();
|
||||
|
||||
@@ -91,10 +101,22 @@ class AppServiceProvider extends ServiceProvider
|
||||
\App\Events\Posts\PostCommented::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)
|
||||
View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) {
|
||||
$uploadCount = $favCount = $msgCount = $noticeCount = 0;
|
||||
$uploadCount = $favCount = $msgCount = $noticeCount = $receivedCommentsCount = 0;
|
||||
$avatarHash = null;
|
||||
$displayName = null;
|
||||
$userId = null;
|
||||
@@ -130,11 +152,18 @@ class AppServiceProvider extends ServiceProvider
|
||||
}
|
||||
|
||||
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) {
|
||||
$noticeCount = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$receivedCommentsCount = $this->app->make(ReceivedCommentsInboxService::class)
|
||||
->unreadCountForUser(Auth::user());
|
||||
} catch (\Throwable $e) {
|
||||
$receivedCommentsCount = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$profile = DB::table('user_profiles')->where('user_id', $userId)->first();
|
||||
$avatarHash = $profile->avatar_hash ?? null;
|
||||
@@ -145,7 +174,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
$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
|
||||
@@ -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
|
||||
{
|
||||
RateLimiter::for('username-check', function (Request $request): Limit {
|
||||
@@ -336,4 +386,20 @@ class AppServiceProvider extends ServiceProvider
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
228
app/Services/AchievementService.php
Normal file
228
app/Services/AchievementService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
34
app/Services/ActivityService.php
Normal file
34
app/Services/ActivityService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\ReactionType;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\CommentReaction;
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMention;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
@@ -74,13 +76,15 @@ final class CommunityActivityService
|
||||
$commentModels = $this->fetchCommentModels($sourceLimit, repliesOnly: false);
|
||||
$replyModels = $this->fetchCommentModels($sourceLimit, repliesOnly: true);
|
||||
$reactionModels = $this->fetchReactionModels($sourceLimit);
|
||||
$recordedActivities = $this->fetchRecordedActivities($sourceLimit);
|
||||
|
||||
$commentActivities = $commentModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'comment'));
|
||||
$replyActivities = $replyModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'reply'));
|
||||
$reactionActivities = $reactionModels->map(fn (CommentReaction $reaction) => $this->mapReactionActivity($reaction));
|
||||
$mentionActivities = $this->fetchMentionActivities($sourceLimit);
|
||||
|
||||
$merged = $commentActivities
|
||||
$merged = $recordedActivities
|
||||
->concat($commentActivities)
|
||||
->concat($replyActivities)
|
||||
->concat($reactionActivities)
|
||||
->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
|
||||
{
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
$artwork = $this->buildArtworkPayload($comment->artwork);
|
||||
|
||||
100
app/Services/Countries/CountryCatalogService.php
Normal file
100
app/Services/Countries/CountryCatalogService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
115
app/Services/Countries/CountryRemoteProvider.php
Normal file
115
app/Services/Countries/CountryRemoteProvider.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/Services/Countries/CountryRemoteProviderInterface.php
Normal file
23
app/Services/Countries/CountryRemoteProviderInterface.php
Normal 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;
|
||||
}
|
||||
193
app/Services/Countries/CountrySyncService.php
Normal file
193
app/Services/Countries/CountrySyncService.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Notifications\UserFollowedNotification;
|
||||
use App\Events\Achievements\AchievementCheckRequested;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
@@ -16,6 +19,8 @@ use Illuminate\Support\Facades\DB;
|
||||
*/
|
||||
final class FollowService
|
||||
{
|
||||
public function __construct(private readonly XPService $xp) {}
|
||||
|
||||
/**
|
||||
* Follow $targetId on behalf of $actorId.
|
||||
*
|
||||
@@ -60,6 +65,15 @@ final class FollowService
|
||||
targetId: $targetId,
|
||||
);
|
||||
} 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;
|
||||
|
||||
553
app/Services/LeaderboardService.php
Normal file
553
app/Services/LeaderboardService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
90
app/Services/NotificationService.php
Normal file
90
app/Services/NotificationService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Achievement;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTarget;
|
||||
use App\Models\User;
|
||||
@@ -67,6 +68,16 @@ class PostAchievementService
|
||||
], $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(
|
||||
|
||||
67
app/Services/ReceivedCommentsInboxService.php
Normal file
67
app/Services/ReceivedCommentsInboxService.php
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
300
app/Services/SocialService.php
Normal file
300
app/Services/SocialService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
72
app/Services/StoryPublicationService.php
Normal file
72
app/Services/StoryPublicationService.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Services;
|
||||
use App\Jobs\IndexUserJob;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* UserStatsService – single source of truth for user_statistics counters.
|
||||
@@ -253,7 +254,7 @@ final class UserStatsService
|
||||
DB::table('user_statistics')
|
||||
->where('user_id', $userId)
|
||||
->update([
|
||||
$column => DB::raw("GREATEST(0, COALESCE({$column}, 0) + {$by})"),
|
||||
$column => $this->nonNegativeCounterExpression($column, $by),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
@@ -264,7 +265,7 @@ final class UserStatsService
|
||||
->where('user_id', $userId)
|
||||
->where($column, '>', 0)
|
||||
->update([
|
||||
$column => DB::raw("GREATEST(0, COALESCE({$column}, 0) - {$by})"),
|
||||
$column => $this->nonNegativeCounterExpression($column, -$by),
|
||||
'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.
|
||||
* Uses IndexUserJob to avoid blocking the request.
|
||||
|
||||
292
app/Services/XPService.php
Normal file
292
app/Services/XPService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class AvatarUrl
|
||||
{
|
||||
@@ -22,16 +21,14 @@ class AvatarUrl
|
||||
}
|
||||
|
||||
$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}
|
||||
$p1 = substr($avatarHash, 0, 2);
|
||||
$p2 = substr($avatarHash, 2, 2);
|
||||
|
||||
$diskPath = sprintf('avatars/%s/%s/%s/%d.webp', $p1, $p2, $avatarHash, $size);
|
||||
|
||||
// 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, $size);
|
||||
return sprintf('%s/avatars/%s/%s/%s/%d.webp', $base, $p1, $p2, $avatarHash, $resolvedSize);
|
||||
}
|
||||
|
||||
public static function default(): string
|
||||
@@ -59,4 +56,26 @@ class AvatarUrl
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
]);
|
||||
|
||||
$middleware->web(append: [
|
||||
\App\Http\Middleware\RedirectLegacyProfileSubdomain::class,
|
||||
\App\Http\Middleware\HandleInertiaRequests::class,
|
||||
// Runs on every web request; no-ops for guests, redirects authenticated
|
||||
// users who have not finished onboarding (e.g. OAuth users awaiting username).
|
||||
|
||||
@@ -7,4 +7,5 @@ return [
|
||||
cPad\Plugins\Artworks\ServiceProvider::class,
|
||||
cPad\Plugins\News\ServiceProvider::class,
|
||||
cPad\Plugins\Forum\ServiceProvider::class,
|
||||
cPad\Plugins\Site\ServiceProvider::class,
|
||||
];
|
||||
|
||||
23
config/skinbase-countries.php
Normal file
23
config/skinbase-countries.php
Normal 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'),
|
||||
];
|
||||
@@ -50,7 +50,7 @@ return [
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'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'),
|
||||
'timeout' => (int) env('LM_STUDIO_TIMEOUT', 60),
|
||||
'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
Reference in New Issue
Block a user