Repair: copy legacy joinDate into new user's created_at when creating users from legacy wallz

This commit is contained in:
2026-03-22 09:13:39 +01:00
parent e8b5edf5d2
commit 2608be7420
80 changed files with 3991 additions and 723 deletions

View File

@@ -47,7 +47,17 @@ QUEUE_CONNECTION=redis
MESSAGING_REALTIME=true MESSAGING_REALTIME=true
MESSAGING_BROADCAST_QUEUE=broadcasts MESSAGING_BROADCAST_QUEUE=broadcasts
MESSAGING_TYPING_TTL=8
MESSAGING_TYPING_CACHE_STORE=redis MESSAGING_TYPING_CACHE_STORE=redis
MESSAGING_PRESENCE_TTL=90
MESSAGING_CONVERSATION_PRESENCE_TTL=45
MESSAGING_PRESENCE_CACHE_STORE=redis
MESSAGING_RECOVERY_MAX_MESSAGES=100
MESSAGING_OFFLINE_FALLBACK_ONLY=true
HORIZON_NAME=skinbase-nova
HORIZON_PATH=horizon
HORIZON_PREFIX=skinbase_nova_horizon:
REVERB_APP_ID=skinbase-local REVERB_APP_ID=skinbase-local
REVERB_APP_KEY=skinbase-local-key REVERB_APP_KEY=skinbase-local-key
@@ -77,6 +87,14 @@ SKINBASE_DUPLICATE_HASH_POLICY=block
VISION_ENABLED=true VISION_ENABLED=true
VISION_QUEUE=default VISION_QUEUE=default
VISION_IMAGE_VARIANT=md VISION_IMAGE_VARIANT=md
VISION_VECTOR_GATEWAY_ENABLED=true
VISION_VECTOR_GATEWAY_URL=
VISION_VECTOR_GATEWAY_API_KEY=
VISION_VECTOR_GATEWAY_COLLECTION=images
VISION_VECTOR_GATEWAY_TIMEOUT=20
VISION_VECTOR_GATEWAY_CONNECT_TIMEOUT=5
VISION_VECTOR_GATEWAY_RETRIES=1
VISION_VECTOR_GATEWAY_RETRY_DELAY_MS=250
# CLIP service (set base URL to enable CLIP calls) # CLIP service (set base URL to enable CLIP calls)
CLIP_BASE_URL= CLIP_BASE_URL=
@@ -101,6 +119,8 @@ RECOMMENDATIONS_AB_ALGO_VERSIONS=clip-cosine-v1
RECOMMENDATIONS_MIN_DIM=64 RECOMMENDATIONS_MIN_DIM=64
RECOMMENDATIONS_MAX_DIM=4096 RECOMMENDATIONS_MAX_DIM=4096
RECOMMENDATIONS_BACKFILL_BATCH=200 RECOMMENDATIONS_BACKFILL_BATCH=200
SIMILARITY_VECTOR_ENABLED=false
SIMILARITY_VECTOR_ADAPTER=pgvector
# Personalized discovery foundation (Phase 8) # Personalized discovery foundation (Phase 8)
DISCOVERY_QUEUE=${RECOMMENDATIONS_QUEUE} DISCOVERY_QUEUE=${RECOMMENDATIONS_QUEUE}

View File

@@ -11,7 +11,7 @@ use Illuminate\Support\Str;
class ImportLegacyUsers extends Command class ImportLegacyUsers extends Command
{ {
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users} {--dry-run : Preview which users would be skipped/deleted without making changes}'; protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users} {--restore-temp-usernames : Restore legacy usernames for existing users still using tmpu12345-style placeholders} {--dry-run : Preview which users would be skipped/deleted without making changes}';
protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec'; protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec';
protected string $migrationLogPath; protected string $migrationLogPath;
@@ -20,7 +20,7 @@ class ImportLegacyUsers extends Command
public function handle(): int public function handle(): int
{ {
$this->migrationLogPath = storage_path('logs/username_migration.log'); $this->migrationLogPath = (string) storage_path('logs/username_migration.log');
@file_put_contents($this->migrationLogPath, '['.now()."] Starting legacy username policy migration\n", FILE_APPEND); @file_put_contents($this->migrationLogPath, '['.now()."] Starting legacy username policy migration\n", FILE_APPEND);
// Build the set of legacy user IDs that have any meaningful activity. // Build the set of legacy user IDs that have any meaningful activity.
@@ -134,8 +134,14 @@ class ImportLegacyUsers extends Command
{ {
$legacyId = (int) $row->user_id; $legacyId = (int) $row->user_id;
// Use legacy username as-is (sanitized only, no numeric suffixing — was unique in old DB). // Use legacy username as-is by default. Placeholder tmp usernames can be
$username = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId))); // restored explicitly with --restore-temp-usernames using safe uniqueness rules.
$existingUser = DB::table('users')
->select(['id', 'username'])
->where('id', $legacyId)
->first();
$username = $this->resolveImportUsername($row, $legacyId, $existingUser?->username ?? null);
$normalizedLegacy = UsernamePolicy::normalize((string) ($row->uname ?? '')); $normalizedLegacy = UsernamePolicy::normalize((string) ($row->uname ?? ''));
if ($normalizedLegacy !== $username) { if ($normalizedLegacy !== $username) {
@@ -173,7 +179,12 @@ class ImportLegacyUsers extends Command
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) { DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
$now = now(); $now = now();
$alreadyExists = DB::table('users')->where('id', $legacyId)->exists(); $existingUser = DB::table('users')
->select(['id', 'username'])
->where('id', $legacyId)
->first();
$alreadyExists = $existingUser !== null;
$previousUsername = (string) ($existingUser?->username ?? '');
// All fields synced from legacy on every run // All fields synced from legacy on every run
$sharedFields = [ $sharedFields = [
@@ -212,7 +223,7 @@ class ImportLegacyUsers extends Command
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null, 'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
'language' => $row->lang ?: null, 'language' => $row->lang ?: null,
'birthdate' => $row->birth ?: null, 'birthdate' => $row->birth ?: null,
'gender' => $row->gender ?: 'X', 'gender' => $this->normalizeLegacyGender($row->gender ?? null),
'website' => $row->web ?: null, 'website' => $row->web ?: null,
'updated_at' => $now, 'updated_at' => $now,
] ]
@@ -232,7 +243,7 @@ class ImportLegacyUsers extends Command
); );
if (Schema::hasTable('username_redirects')) { if (Schema::hasTable('username_redirects')) {
$old = UsernamePolicy::normalize((string) ($row->uname ?? '')); $old = $this->usernameRedirectKey((string) ($row->uname ?? ''));
if ($old !== '' && $old !== $username) { if ($old !== '' && $old !== $username) {
DB::table('username_redirects')->updateOrInsert( DB::table('username_redirects')->updateOrInsert(
['old_username' => $old], ['old_username' => $old],
@@ -244,10 +255,50 @@ class ImportLegacyUsers extends Command
] ]
); );
} }
if ($this->shouldRestoreTemporaryUsername($previousUsername) && $previousUsername !== $username) {
DB::table('username_redirects')->updateOrInsert(
['old_username' => $this->usernameRedirectKey($previousUsername)],
[
'new_username' => $username,
'user_id' => $legacyId,
'created_at' => $now,
'updated_at' => $now,
]
);
}
} }
}); });
} }
protected function resolveImportUsername(object $row, int $legacyId, ?string $existingUsername = null): string
{
$legacyUsername = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId)));
if (! $this->option('restore-temp-usernames')) {
return $legacyUsername;
}
if ($existingUsername === null || $existingUsername === '') {
return $legacyUsername;
}
if (! $this->shouldRestoreTemporaryUsername($existingUsername)) {
return $existingUsername;
}
return UsernamePolicy::uniqueCandidate((string) ($row->uname ?: ('user' . $legacyId)), $legacyId);
}
protected function shouldRestoreTemporaryUsername(?string $username): bool
{
if (! is_string($username) || trim($username) === '') {
return false;
}
return preg_match('/^tmpu\d+$/i', trim($username)) === 1;
}
/** /**
* Ensure statistic values are safe for unsigned DB columns. * Ensure statistic values are safe for unsigned DB columns.
*/ */
@@ -265,6 +316,24 @@ class ImportLegacyUsers extends Command
return UsernamePolicy::sanitizeLegacy($username); return UsernamePolicy::sanitizeLegacy($username);
} }
protected function usernameRedirectKey(?string $username): string
{
$value = $this->sanitizeUsername((string) ($username ?? ''));
return $value === 'user' && trim((string) ($username ?? '')) === '' ? '' : $value;
}
protected function normalizeLegacyGender(mixed $value): ?string
{
$normalized = strtoupper(trim((string) ($value ?? '')));
return match ($normalized) {
'M', 'MALE', 'MAN', 'BOY' => 'M',
'F', 'FEMALE', 'WOMAN', 'GIRL' => 'F',
default => null,
};
}
protected function sanitizeEmailLocal(string $value): string protected function sanitizeEmailLocal(string $value): string
{ {
$local = strtolower(trim($value)); $local = strtolower(trim($value));

View File

@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Models\Category;
use App\Services\Vision\ArtworkVisionImageUrl;
use App\Services\Vision\VectorGatewayClient;
use Illuminate\Console\Command;
final class IndexArtworkVectorsCommand extends Command
{
protected $signature = 'artworks:vectors-index
{--start-id=0 : Start from this artwork id (inclusive)}
{--after-id=0 : Resume after this artwork id}
{--batch=100 : Batch size per iteration}
{--limit=0 : Maximum artworks to process in this run}
{--public-only : Index only public, approved, published artworks}
{--dry-run : Preview requests without sending them}';
protected $description = 'Send artwork image URLs to the vector gateway for indexing';
public function handle(VectorGatewayClient $client, ArtworkVisionImageUrl $imageUrl): int
{
$dryRun = (bool) $this->option('dry-run');
if (! $dryRun && ! $client->isConfigured()) {
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
return self::FAILURE;
}
$startId = max(0, (int) $this->option('start-id'));
$afterId = max(0, (int) $this->option('after-id'));
$batch = max(1, min((int) $this->option('batch'), 1000));
$limit = max(0, (int) $this->option('limit'));
$publicOnly = (bool) $this->option('public-only');
$nextId = $startId > 0 ? $startId : max(1, $afterId + 1);
$processed = 0;
$indexed = 0;
$skipped = 0;
$failed = 0;
$lastId = $afterId;
if ($startId > 0 && $afterId > 0) {
$this->warn(sprintf(
'Both --start-id=%d and --after-id=%d were provided. Using --start-id and ignoring --after-id.',
$startId,
$afterId
));
}
$this->info(sprintf(
'Starting vector index: start_id=%d after_id=%d next_id=%d batch=%d limit=%s public_only=%s dry_run=%s',
$startId,
$afterId,
$nextId,
$batch,
$limit > 0 ? (string) $limit : 'all',
$publicOnly ? 'yes' : 'no',
$dryRun ? 'yes' : 'no'
));
while (true) {
$remaining = $limit > 0 ? max(0, $limit - $processed) : $batch;
if ($limit > 0 && $remaining === 0) {
break;
}
$take = $limit > 0 ? min($batch, $remaining) : $batch;
$query = Artwork::query()
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
->where('id', '>=', $nextId)
->whereNotNull('hash')
->orderBy('id')
->limit($take);
if ($publicOnly) {
$query->public()->published();
}
$artworks = $query->get();
if ($artworks->isEmpty()) {
$this->line('No more artworks matched the current query window.');
break;
}
$this->line(sprintf(
'Fetched batch: count=%d first_id=%d last_id=%d',
$artworks->count(),
(int) $artworks->first()->id,
(int) $artworks->last()->id
));
foreach ($artworks as $artwork) {
$processed++;
$lastId = (int) $artwork->id;
$nextId = $lastId + 1;
$url = $imageUrl->fromArtwork($artwork);
if ($url === null) {
$skipped++;
$this->warn("Skipped artwork {$artwork->id}: no vision image URL could be generated.");
continue;
}
$metadata = $this->metadataForArtwork($artwork);
$this->line(sprintf(
'Processing artwork=%d hash=%s thumb_ext=%s url=%s metadata=%s',
(int) $artwork->id,
(string) ($artwork->hash ?? ''),
(string) ($artwork->thumb_ext ?? ''),
$url,
$this->json($metadata)
));
if ($dryRun) {
$indexed++;
$this->line(sprintf(
'[dry] artwork=%d indexed=%d/%d',
(int) $artwork->id,
$indexed,
$processed
));
continue;
}
try {
$client->upsertByUrl($url, (int) $artwork->id, $metadata);
$indexed++;
$this->info(sprintf(
'Indexed artwork %d successfully. totals: processed=%d indexed=%d skipped=%d failed=%d',
(int) $artwork->id,
$processed,
$indexed,
$skipped,
$failed
));
} catch (\Throwable $e) {
$failed++;
$this->warn("Failed artwork {$artwork->id}: {$e->getMessage()}");
}
}
}
$this->info("Vector index finished. processed={$processed} indexed={$indexed} skipped={$skipped} failed={$failed} last_id={$lastId} next_id={$nextId}");
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @param array<string, string> $payload
*/
private function json(array $payload): string
{
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return is_string($json) ? $json : '{}';
}
/**
* @return array{content_type: string, category: string, user_id: string}
*/
private function metadataForArtwork(Artwork $artwork): array
{
$category = $this->primaryCategory($artwork);
return [
'content_type' => (string) ($category?->contentType?->name ?? ''),
'category' => (string) ($category?->name ?? ''),
'user_id' => (string) ($artwork->user_id ?? ''),
];
}
private function primaryCategory(Artwork $artwork): ?Category
{
/** @var Category|null $category */
$category = $artwork->categories->sortBy('sort_order')->first();
return $category;
}
}

View File

@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Carbon\Carbon;
class RepairLegacyWallzUsersCommand extends Command
{
protected $signature = 'skinbase:repair-legacy-wallz-users
{--chunk=500 : Number of legacy wallz rows to scan per batch}
{--legacy-connection=legacy : Legacy database connection name}
{--legacy-table=wallz : Legacy table to update}
{--artworks-table=artworks : Current DB artworks table name}
{--fix-artworks : Backfill `artworks.user_id` from legacy `wallz.user_id` for rows where user_id = 0}
{--dry-run : Preview matches and inserts without writing changes}';
protected $description = 'Backfill legacy wallz.user_id from uname by matching or creating users in the new users table';
public function handle(): int
{
$chunk = max(1, (int) $this->option('chunk'));
$legacyConnection = (string) $this->option('legacy-connection');
$legacyTable = (string) $this->option('legacy-table');
$artworksTable = (string) $this->option('artworks-table');
$fixArtworks = (bool) $this->option('fix-artworks');
$dryRun = (bool) $this->option('dry-run');
if (! $this->legacyTableExists($legacyConnection, $legacyTable)) {
$this->error("Legacy table {$legacyConnection}.{$legacyTable} does not exist or the connection is unavailable.");
return self::FAILURE;
}
if ($dryRun) {
$this->warn('[DRY RUN] No changes will be written.');
}
if ($fixArtworks) {
$this->handleFixArtworks($chunk, $legacyConnection, $legacyTable, $artworksTable, $dryRun);
}
$total = (int) DB::connection($legacyConnection)
->table($legacyTable)
->where('user_id', 0)
->count();
if ($total === 0) {
if (! $fixArtworks) {
$this->info('No legacy wallz rows with user_id = 0 were found.');
}
return self::SUCCESS;
}
$this->info("Scanning {$total} legacy rows in {$legacyConnection}.{$legacyTable}.");
$processed = 0;
$updatedRows = 0;
$matchedUsers = 0;
$createdUsers = 0;
$skippedRows = 0;
$usernameMap = [];
DB::connection($legacyConnection)
->table($legacyTable)
->select(['id', 'uname'])
->where('user_id', 0)
->orderBy('id')
->chunkById($chunk, function ($rows) use (
&$processed,
&$updatedRows,
&$matchedUsers,
&$createdUsers,
&$skippedRows,
&$usernameMap,
$dryRun,
$legacyConnection,
$legacyTable
) {
foreach ($rows as $row) {
$processed++;
$rawUsername = trim((string) ($row->uname ?? ''));
if ($rawUsername === '') {
$skippedRows++;
$this->warn("Skipping wallz id={$row->id}: uname is empty.");
continue;
}
$lookupKey = UsernamePolicy::normalize($rawUsername);
if ($lookupKey === '') {
$skippedRows++;
$this->warn("Skipping wallz id={$row->id}: uname normalizes to empty.");
continue;
}
if (! array_key_exists($lookupKey, $usernameMap)) {
$existingUser = $this->findUserByUsername($lookupKey);
if ($existingUser !== null) {
$usernameMap[$lookupKey] = [
'user_id' => (int) $existingUser->id,
'created' => false,
];
} else {
$usernameMap[$lookupKey] = [
'user_id' => $dryRun
? 0
: $this->createUserForLegacyUsername($rawUsername, $legacyConnection),
'created' => true,
];
}
}
$resolved = $usernameMap[$lookupKey];
if ($resolved['created']) {
$createdUsers++;
$usernameMap[$lookupKey]['created'] = false;
$resolved['created'] = false;
$this->line($dryRun
? "[dry] Would create user for uname='{$rawUsername}'"
: "[create] Created user_id={$usernameMap[$lookupKey]['user_id']} for uname='{$rawUsername}'");
} else {
$matchedUsers++;
}
if ($dryRun) {
$targetUser = $usernameMap[$lookupKey]['user_id'] > 0
? (string) $usernameMap[$lookupKey]['user_id']
: '<new-user-id>';
$this->line("[dry] Would update wallz id={$row->id} to user_id={$targetUser} using uname='{$rawUsername}'");
$updatedRows++;
continue;
}
$affected = DB::connection($legacyConnection)
->table($legacyTable)
->where('id', $row->id)
->where('user_id', 0)
->update([
'user_id' => $usernameMap[$lookupKey]['user_id'],
]);
if ($affected > 0) {
$updatedRows += $affected;
}
}
}, 'id');
$this->info(sprintf(
'Finished. processed=%d updated=%d matched=%d created=%d skipped=%d',
$processed,
$updatedRows,
$matchedUsers,
$createdUsers,
$skippedRows
));
return self::SUCCESS;
}
private function handleFixArtworks(int $chunk, string $legacyConnection, string $legacyTable, string $artworksTable, bool $dryRun): void
{
$this->info("\nAttempting to backfill `{$artworksTable}.user_id` from legacy {$legacyConnection}.{$legacyTable} where user_id = 0");
$total = (int) DB::table($artworksTable)->where('user_id', 0)->count();
$this->info("Found {$total} rows in {$artworksTable} with user_id = 0. Chunk size: {$chunk}.");
$processed = 0;
$updated = 0;
DB::table($artworksTable)
->select(['id'])
->where('user_id', 0)
->orderBy('id')
->chunkById($chunk, function ($rows) use (&$processed, &$updated, $legacyConnection, $legacyTable, $artworksTable, $dryRun) {
foreach ($rows as $row) {
$processed++;
$legacyUser = DB::connection($legacyConnection)
->table($legacyTable)
->where('id', $row->id)
->value('user_id');
$legacyUser = (int) ($legacyUser ?? 0);
if ($legacyUser <= 0) {
continue;
}
if ($dryRun) {
$this->line("[dry] Would update {$artworksTable} id={$row->id} to user_id={$legacyUser}");
$updated++;
continue;
}
$affected = DB::table($artworksTable)
->where('id', $row->id)
->where('user_id', 0)
->update(['user_id' => $legacyUser]);
if ($affected > 0) {
$updated += $affected;
}
}
}, 'id');
$this->info(sprintf('Artworks backfill complete. processed=%d updated=%d', $processed, $updated));
}
private function legacyTableExists(string $connection, string $table): bool
{
try {
return DB::connection($connection)->getSchemaBuilder()->hasTable($table);
} catch (\Throwable) {
return false;
}
}
private function findUserByUsername(string $normalizedUsername): ?object
{
return DB::table('users')
->select(['id', 'username'])
->whereRaw('LOWER(username) = ?', [$normalizedUsername])
->first();
}
private function createUserForLegacyUsername(string $legacyUsername, string $legacyConnection): int
{
$username = UsernamePolicy::uniqueCandidate($legacyUsername);
$emailLocal = $this->sanitizeEmailLocal($username);
$email = $this->uniqueEmailCandidate($emailLocal . '@users.skinbase.org');
$now = now();
// Attempt to copy legacy joinDate from the legacy `users` table when available.
$legacyJoin = null;
try {
$legacyJoin = DB::connection($legacyConnection)
->table('users')
->whereRaw('LOWER(uname) = ?', [strtolower((string) $legacyUsername)])
->value('joinDate');
} catch (\Throwable) {
$legacyJoin = null;
}
$createdAt = $now;
if (! empty($legacyJoin) && strpos((string) $legacyJoin, '0000') !== 0) {
try {
$createdAt = Carbon::parse($legacyJoin);
} catch (\Throwable) {
$createdAt = $now;
}
}
$userId = (int) DB::table('users')->insertGetId([
'username' => $username,
'username_changed_at' => $now,
'name' => $legacyUsername,
'email' => $email,
'password' => Hash::make(Str::random(64)),
'is_active' => true,
'needs_password_reset' => true,
'role' => 'user',
'legacy_password_algo' => null,
'created_at' => $createdAt,
'updated_at' => $now,
]);
return $userId;
}
private function uniqueEmailCandidate(string $email): string
{
$candidate = strtolower(trim($email));
$suffix = 1;
while (DB::table('users')->whereRaw('LOWER(email) = ?', [$candidate])->exists()) {
$parts = explode('@', $email, 2);
$local = $parts[0] ?? 'user';
$domain = $parts[1] ?? 'users.skinbase.org';
$candidate = $local . '+' . $suffix . '@' . $domain;
$suffix++;
}
return $candidate;
}
private function sanitizeEmailLocal(string $value): string
{
$local = strtolower(trim($value));
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
return trim($local, '.-') ?: 'user';
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RepairTemporaryUsernamesCommand extends Command
{
protected $signature = 'skinbase:repair-temp-usernames
{--chunk=500 : Number of users to process per batch}
{--dry-run : Preview username changes without writing them}';
protected $description = 'Replace current users.username values like tmpu% using the users.name field';
public function handle(): int
{
$chunk = max(1, (int) $this->option('chunk'));
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('[DRY RUN] No changes will be written.');
}
$total = (int) DB::table('users')
->where('username', 'like', 'tmpu%')
->count();
if ($total === 0) {
$this->info('No users with temporary tmpu% usernames were found.');
return self::SUCCESS;
}
$this->info("Found {$total} users with temporary tmpu% usernames.");
$processed = 0;
$updated = 0;
$skipped = 0;
DB::table('users')
->select(['id', 'name', 'username'])
->where('username', 'like', 'tmpu%')
->chunkById($chunk, function ($rows) use (&$processed, &$updated, &$skipped, $dryRun) {
foreach ($rows as $row) {
$processed++;
$sourceName = trim((string) ($row->name ?? ''));
if ($sourceName === '') {
$skipped++;
$this->warn("Skipping user id={$row->id}: name is empty.");
continue;
}
$candidate = $this->resolveCandidate($sourceName, (int) $row->id);
if ($candidate === null || strcasecmp($candidate, (string) $row->username) === 0) {
$skipped++;
$this->warn("Skipping user id={$row->id}: unable to resolve a better username from name='{$sourceName}'.");
continue;
}
if ($dryRun) {
$this->line("[dry] Would update user id={$row->id} username '{$row->username}' => '{$candidate}'");
$updated++;
continue;
}
$affected = DB::table('users')
->where('id', (int) $row->id)
->where('username', 'like', 'tmpu%')
->update([
'username' => $candidate,
'username_changed_at' => now(),
'updated_at' => now(),
]);
if ($affected > 0) {
$updated += $affected;
$this->line("[update] user id={$row->id} username '{$row->username}' => '{$candidate}'");
}
}
}, 'id');
$this->info(sprintf('Finished. processed=%d updated=%d skipped=%d', $processed, $updated, $skipped));
return self::SUCCESS;
}
private function resolveCandidate(string $sourceName, int $userId): ?string
{
$base = UsernamePolicy::sanitizeLegacy($sourceName);
$min = UsernamePolicy::min();
$max = UsernamePolicy::max();
if ($base === '') {
return null;
}
if (preg_match('/^tmpu\d+$/i', $base) === 1) {
$base = 'user' . $userId;
}
if (strlen($base) < $min) {
$base = substr($base . $userId, 0, $max);
}
if ($base === '' || $base === 'user') {
$base = 'user' . $userId;
}
$candidate = substr($base, 0, $max);
$suffix = 1;
while ($this->usernameExists($candidate, $userId) || UsernamePolicy::isReserved($candidate)) {
$suffixValue = (string) $suffix;
$prefixLen = max(1, $max - strlen($suffixValue));
$candidate = substr($base, 0, $prefixLen) . $suffixValue;
$suffix++;
}
return $candidate;
}
private function usernameExists(string $username, int $ignoreUserId): bool
{
return DB::table('users')
->whereRaw('LOWER(username) = ?', [strtolower($username)])
->where('id', '!=', $ignoreUserId)
->exists();
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Models\Category;
use App\Services\Vision\ArtworkVisionImageUrl;
use App\Services\Vision\VectorGatewayClient;
use Illuminate\Console\Command;
final class SearchArtworkVectorsCommand extends Command
{
protected $signature = 'artworks:vectors-search
{artwork_id : Source artwork id}
{--limit=5 : Number of similar artworks to return}';
protected $description = 'Search similar artworks through the vector gateway using an artwork image URL';
public function handle(VectorGatewayClient $client, ArtworkVisionImageUrl $imageUrl): int
{
if (! $client->isConfigured()) {
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
return self::FAILURE;
}
$artworkId = max(1, (int) $this->argument('artwork_id'));
$limit = max(1, min((int) $this->option('limit'), 100));
$artwork = Artwork::query()
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
->find($artworkId);
if (! $artwork) {
$this->error("Artwork {$artworkId} was not found.");
return self::FAILURE;
}
$url = $imageUrl->fromArtwork($artwork);
if ($url === null) {
$this->error("Artwork {$artworkId} does not have a usable CDN image URL.");
return self::FAILURE;
}
try {
$matches = $client->searchByUrl($url, $limit + 1);
} catch (\Throwable $e) {
$this->error('Vector search failed: ' . $e->getMessage());
return self::FAILURE;
}
$ids = collect($matches)
->map(fn (array $match): int => (int) $match['id'])
->filter(fn (int $id): bool => $id > 0 && $id !== $artworkId)
->unique()
->take($limit)
->values()
->all();
if ($ids === []) {
$this->warn('No similar artworks were returned by the vector gateway.');
return self::SUCCESS;
}
$artworks = Artwork::query()
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
->whereIn('id', $ids)
->public()
->published()
->get()
->keyBy('id');
$rows = [];
foreach ($matches as $match) {
$matchId = (int) ($match['id'] ?? 0);
if ($matchId <= 0 || $matchId === $artworkId) {
continue;
}
/** @var Artwork|null $matchedArtwork */
$matchedArtwork = $artworks->get($matchId);
if (! $matchedArtwork) {
continue;
}
$category = $this->primaryCategory($matchedArtwork);
$rows[] = [
'id' => $matchId,
'score' => number_format((float) ($match['score'] ?? 0.0), 4, '.', ''),
'title' => (string) $matchedArtwork->title,
'content_type' => (string) ($category?->contentType?->name ?? ''),
'category' => (string) ($category?->name ?? ''),
];
if (count($rows) >= $limit) {
break;
}
}
if ($rows === []) {
$this->warn('The vector gateway returned matches, but none resolved to public published artworks.');
return self::SUCCESS;
}
$this->table(['ID', 'Score', 'Title', 'Content Type', 'Category'], $rows);
return self::SUCCESS;
}
private function primaryCategory(Artwork $artwork): ?Category
{
/** @var Category|null $category */
$category = $artwork->categories->sortBy('sort_order')->first();
return $category;
}
}

View File

@@ -7,6 +7,8 @@ use App\Console\Commands\ImportLegacyUsers;
use App\Console\Commands\ImportCategories; use App\Console\Commands\ImportCategories;
use App\Console\Commands\MigrateFeaturedWorks; use App\Console\Commands\MigrateFeaturedWorks;
use App\Console\Commands\BackfillArtworkEmbeddingsCommand; use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
use App\Console\Commands\IndexArtworkVectorsCommand;
use App\Console\Commands\SearchArtworkVectorsCommand;
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand; use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
use App\Console\Commands\AggregateFeedAnalyticsCommand; use App\Console\Commands\AggregateFeedAnalyticsCommand;
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand; use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
@@ -43,6 +45,8 @@ class Kernel extends ConsoleKernel
CleanupUploadsCommand::class, CleanupUploadsCommand::class,
PublishScheduledArtworksCommand::class, PublishScheduledArtworksCommand::class,
BackfillArtworkEmbeddingsCommand::class, BackfillArtworkEmbeddingsCommand::class,
IndexArtworkVectorsCommand::class,
SearchArtworkVectorsCommand::class,
AggregateSimilarArtworkAnalyticsCommand::class, AggregateSimilarArtworkAnalyticsCommand::class,
AggregateFeedAnalyticsCommand::class, AggregateFeedAnalyticsCommand::class,
AggregateTagInteractionAnalyticsCommand::class, AggregateTagInteractionAnalyticsCommand::class,

View File

@@ -4,6 +4,7 @@ namespace App\Events;
use App\Models\Conversation; use App\Models\Conversation;
use App\Services\Messaging\MessagingPayloadFactory; use App\Services\Messaging\MessagingPayloadFactory;
use App\Services\Messaging\UnreadCounterService;
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
@@ -41,6 +42,9 @@ class ConversationUpdated implements ShouldBroadcast
'event' => 'conversation.updated', 'event' => 'conversation.updated',
'reason' => $this->reason, 'reason' => $this->reason,
'conversation' => app(MessagingPayloadFactory::class)->conversationSummary($this->conversation, $this->userId), 'conversation' => app(MessagingPayloadFactory::class)->conversationSummary($this->conversation, $this->userId),
'summary' => [
'unread_total' => app(UnreadCounterService::class)->totalUnreadForUser($this->userId),
],
]; ];
} }
} }

View File

@@ -48,4 +48,4 @@ class MessageCreated implements ShouldBroadcast
'message' => app(MessagingPayloadFactory::class)->message($this->message, (int) $this->message->sender_id), 'message' => app(MessagingPayloadFactory::class)->message($this->message, (int) $this->message->sender_id),
]; ];
} }
} }

View File

@@ -42,4 +42,4 @@ class MessageDeleted implements ShouldBroadcast
'deleted_at' => optional($this->message->deleted_at ?? now())?->toIso8601String(), 'deleted_at' => optional($this->message->deleted_at ?? now())?->toIso8601String(),
]; ];
} }
} }

View File

@@ -48,4 +48,4 @@ class MessageRead implements ShouldBroadcast
'last_read_at' => optional($this->participant->last_read_at)?->toIso8601String(), 'last_read_at' => optional($this->participant->last_read_at)?->toIso8601String(),
]; ];
} }
} }

View File

@@ -41,4 +41,4 @@ class MessageUpdated implements ShouldBroadcast
'message' => app(MessagingPayloadFactory::class)->message($this->message), 'message' => app(MessagingPayloadFactory::class)->message($this->message),
]; ];
} }
} }

View File

@@ -197,7 +197,7 @@ class ArtworkCommentController extends Controller
'id' => $c->id, 'id' => $c->id,
'parent_id' => $c->parent_id, 'parent_id' => $c->parent_id,
'raw_content' => $c->raw_content ?? $c->content, 'raw_content' => $c->raw_content ?? $c->content,
'rendered_content' => $c->rendered_content ?? e(strip_tags($c->content ?? '')), 'rendered_content' => $this->renderCommentContent($c),
'created_at' => $c->created_at?->toIso8601String(), 'created_at' => $c->created_at?->toIso8601String(),
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null, 'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
'can_edit' => $currentUserId === $userId, 'can_edit' => $currentUserId === $userId,
@@ -224,6 +224,31 @@ class ArtworkCommentController extends Controller
return $data; return $data;
} }
private function renderCommentContent(ArtworkComment $comment): string
{
$rawContent = (string) ($comment->raw_content ?? $comment->content ?? '');
$renderedContent = $comment->rendered_content;
if (! is_string($renderedContent) || trim($renderedContent) === '') {
$renderedContent = $rawContent !== ''
? ContentSanitizer::render($rawContent)
: nl2br(e(strip_tags((string) ($comment->content ?? ''))));
}
return ContentSanitizer::sanitizeRenderedHtml(
$renderedContent,
$this->commentAuthorCanPublishLinks($comment)
);
}
private function commentAuthorCanPublishLinks(ArtworkComment $comment): bool
{
$level = (int) ($comment->user?->level ?? 1);
$rank = strtolower((string) ($comment->user?->rank ?? 'Newbie'));
return $level > 1 && $rank !== 'newbie';
}
private function notifyRecipients(Artwork $artwork, ArtworkComment $comment, User $actor, ?int $parentId): void private function notifyRecipients(Artwork $artwork, ArtworkComment $comment, User $actor, ?int $parentId): void
{ {
$notifiedUserIds = []; $notifiedUserIds = [];

View File

@@ -9,10 +9,11 @@ use App\Http\Requests\Messaging\RenameConversationRequest;
use App\Http\Requests\Messaging\StoreConversationRequest; use App\Http\Requests\Messaging\StoreConversationRequest;
use App\Models\Conversation; use App\Models\Conversation;
use App\Models\ConversationParticipant; use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User; use App\Models\User;
use App\Services\Messaging\ConversationReadService;
use App\Services\Messaging\ConversationStateService; use App\Services\Messaging\ConversationStateService;
use App\Services\Messaging\SendMessageAction; use App\Services\Messaging\SendMessageAction;
use App\Services\Messaging\UnreadCounterService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@@ -23,7 +24,9 @@ class ConversationController extends Controller
{ {
public function __construct( public function __construct(
private readonly ConversationStateService $conversationState, private readonly ConversationStateService $conversationState,
private readonly ConversationReadService $conversationReads,
private readonly SendMessageAction $sendMessage, private readonly SendMessageAction $sendMessage,
private readonly UnreadCounterService $unreadCounters,
) {} ) {}
// ── GET /api/messages/conversations ───────────────────────────────────── // ── GET /api/messages/conversations ─────────────────────────────────────
@@ -36,26 +39,13 @@ class ConversationController extends Controller
$cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion); $cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion);
$conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) { $conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) {
return Conversation::query() $query = Conversation::query()
->select('conversations.*') ->select('conversations.*')
->join('conversation_participants as cp_me', function ($join) use ($user) { ->join('conversation_participants as cp_me', function ($join) use ($user) {
$join->on('cp_me.conversation_id', '=', 'conversations.id') $join->on('cp_me.conversation_id', '=', 'conversations.id')
->where('cp_me.user_id', '=', $user->id) ->where('cp_me.user_id', '=', $user->id)
->whereNull('cp_me.left_at'); ->whereNull('cp_me.left_at');
}) })
->addSelect([
'unread_count' => Message::query()
->selectRaw('count(*)')
->whereColumn('messages.conversation_id', 'conversations.id')
->where('messages.sender_id', '!=', $user->id)
->whereNull('messages.deleted_at')
->where(function ($query) {
$query->whereNull('cp_me.last_read_message_id')
->whereNull('cp_me.last_read_at')
->orWhereColumn('messages.id', '>', 'cp_me.last_read_message_id')
->orWhereColumn('messages.created_at', '>', 'cp_me.last_read_at');
}),
])
->where('conversations.is_active', true) ->where('conversations.is_active', true)
->with([ ->with([
'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']), 'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']),
@@ -64,8 +54,11 @@ class ConversationController extends Controller
->orderByDesc('cp_me.is_pinned') ->orderByDesc('cp_me.is_pinned')
->orderByDesc('cp_me.pinned_at') ->orderByDesc('cp_me.pinned_at')
->orderByDesc('last_message_at') ->orderByDesc('last_message_at')
->orderByDesc('conversations.id') ->orderByDesc('conversations.id');
->paginate(20, ['conversations.*'], 'page', $page);
$this->unreadCounters->applyUnreadCountSelect($query, $user, 'cp_me');
return $query->paginate(20, ['conversations.*'], 'page', $page);
}); });
$conversations->through(function ($conv) use ($user) { $conversations->through(function ($conv) use ($user) {
@@ -74,7 +67,12 @@ class ConversationController extends Controller
return $conv; return $conv;
}); });
return response()->json($conversations); return response()->json([
...$conversations->toArray(),
'summary' => [
'unread_total' => $this->unreadCounters->totalUnreadForUser($user),
],
]);
} }
// ── GET /api/messages/conversation/{id} ───────────────────────────────── // ── GET /api/messages/conversation/{id} ─────────────────────────────────
@@ -110,7 +108,7 @@ class ConversationController extends Controller
public function markRead(Request $request, int $id): JsonResponse public function markRead(Request $request, int $id): JsonResponse
{ {
$conversation = $this->findAuthorized($request, $id); $conversation = $this->findAuthorized($request, $id);
$participant = $this->conversationState->markConversationRead( $participant = $this->conversationReads->markConversationRead(
$conversation, $conversation,
$request->user(), $request->user(),
$request->integer('message_id') ?: null, $request->integer('message_id') ?: null,
@@ -120,6 +118,7 @@ class ConversationController extends Controller
'ok' => true, 'ok' => true,
'last_read_at' => optional($participant->last_read_at)?->toIso8601String(), 'last_read_at' => optional($participant->last_read_at)?->toIso8601String(),
'last_read_message_id' => $participant->last_read_message_id, 'last_read_message_id' => $participant->last_read_message_id,
'unread_total' => $this->unreadCounters->totalUnreadForUser($request->user()),
]); ]);
} }

View File

@@ -13,6 +13,7 @@ use App\Models\Conversation;
use App\Models\ConversationParticipant; use App\Models\ConversationParticipant;
use App\Models\Message; use App\Models\Message;
use App\Models\MessageReaction; use App\Models\MessageReaction;
use App\Services\Messaging\ConversationDeltaService;
use App\Services\Messaging\ConversationStateService; use App\Services\Messaging\ConversationStateService;
use App\Services\Messaging\MessagingPayloadFactory; use App\Services\Messaging\MessagingPayloadFactory;
use App\Services\Messaging\MessageSearchIndexer; use App\Services\Messaging\MessageSearchIndexer;
@@ -26,6 +27,7 @@ class MessageController extends Controller
private const PAGE_SIZE = 30; private const PAGE_SIZE = 30;
public function __construct( public function __construct(
private readonly ConversationDeltaService $conversationDelta,
private readonly ConversationStateService $conversationState, private readonly ConversationStateService $conversationState,
private readonly MessagingPayloadFactory $payloadFactory, private readonly MessagingPayloadFactory $payloadFactory,
private readonly SendMessageAction $sendMessage, private readonly SendMessageAction $sendMessage,
@@ -40,15 +42,7 @@ class MessageController extends Controller
$afterId = $request->integer('after_id'); $afterId = $request->integer('after_id');
if ($afterId) { if ($afterId) {
$messages = Message::withTrashed() $messages = $this->conversationDelta->messagesAfter($conversation, $request->user(), $afterId);
->where('conversation_id', $conversationId)
->with(['sender:id,username', 'reactions', 'attachments'])
->where('id', '>', $afterId)
->orderBy('id')
->limit(100)
->get()
->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $request->user()->id))
->values();
return response()->json([ return response()->json([
'data' => $messages, 'data' => $messages,
@@ -77,6 +71,18 @@ class MessageController extends Controller
]); ]);
} }
public function delta(Request $request, int $conversationId): JsonResponse
{
$conversation = $this->findConversationOrFail($conversationId);
$afterMessageId = max(0, (int) $request->integer('after_message_id'));
abort_if($afterMessageId < 1, 422, 'after_message_id is required.');
return response()->json([
'data' => $this->conversationDelta->messagesAfter($conversation, $request->user(), $afterMessageId),
]);
}
// ── POST /api/messages/{conversation_id} ───────────────────────────────── // ── POST /api/messages/{conversation_id} ─────────────────────────────────
public function store(StoreMessageRequest $request, int $conversationId): JsonResponse public function store(StoreMessageRequest $request, int $conversationId): JsonResponse

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Http\Controllers\Controller;
use App\Models\Conversation;
use App\Services\Messaging\MessagingPresenceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PresenceController extends Controller
{
public function __construct(
private readonly MessagingPresenceService $presence,
) {}
public function heartbeat(Request $request): JsonResponse
{
$conversationId = $request->integer('conversation_id') ?: null;
if ($conversationId) {
$conversation = Conversation::query()->findOrFail($conversationId);
$this->authorize('view', $conversation);
}
$this->presence->touch($request->user(), $conversationId);
return response()->json([
'ok' => true,
'conversation_id' => $conversationId,
]);
}
}

View File

@@ -37,7 +37,14 @@ final class ProfileApiController extends Controller
$isOwner = Auth::check() && Auth::id() === $user->id; $isOwner = Auth::check() && Auth::id() === $user->id;
$sort = $request->input('sort', 'latest'); $sort = $request->input('sort', 'latest');
$query = Artwork::with('user:id,name,username') $query = Artwork::with([
'user:id,name,username,level,rank',
'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['contentType:id,slug,name']);
},
])
->where('user_id', $user->id) ->where('user_id', $user->id)
->whereNull('deleted_at'); ->whereNull('deleted_at');
@@ -106,7 +113,14 @@ final class ProfileApiController extends Controller
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]); return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
} }
$indexed = Artwork::with('user:id,name,username') $indexed = Artwork::with([
'user:id,name,username,level,rank',
'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['contentType:id,slug,name']);
},
])
->whereIn('id', $favIds) ->whereIn('id', $favIds)
->get() ->get()
->keyBy('id'); ->keyBy('id');
@@ -173,6 +187,9 @@ final class ProfileApiController extends Controller
private function mapArtworkCardPayload(Artwork $art): array private function mapArtworkCardPayload(Artwork $art): array
{ {
$present = ThumbnailPresenter::present($art, 'md'); $present = ThumbnailPresenter::present($art, 'md');
$category = $art->categories->first();
$contentType = $category?->contentType;
$stats = $art->stats;
return [ return [
'id' => $art->id, 'id' => $art->id,
@@ -183,6 +200,13 @@ final class ProfileApiController extends Controller
'height' => $art->height, 'height' => $art->height,
'username' => $art->user->username ?? null, 'username' => $art->user->username ?? null,
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase', 'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
'content_type' => $contentType?->name,
'content_type_slug' => $contentType?->slug,
'category' => $category?->name,
'category_slug' => $category?->slug,
'views' => (int) ($stats?->views ?? $art->view_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0),
'published_at' => $this->formatIsoDate($art->published_at), 'published_at' => $this->formatIsoDate($art->published_at),
]; ];
} }

View File

@@ -49,6 +49,18 @@ use Inertia\Inertia;
class ProfileController extends Controller class ProfileController extends Controller
{ {
private const PROFILE_TABS = [
'posts',
'artworks',
'stories',
'achievements',
'collections',
'about',
'stats',
'favourites',
'activity',
];
public function __construct( public function __construct(
private readonly ArtworkService $artworkService, private readonly ArtworkService $artworkService,
private readonly UsernameApprovalService $usernameApprovalService, private readonly UsernameApprovalService $usernameApprovalService,
@@ -84,7 +96,12 @@ class ProfileController extends Controller
return redirect()->route('profile.show', ['username' => strtolower((string) $user->username)], 301); return redirect()->route('profile.show', ['username' => strtolower((string) $user->username)], 301);
} }
return $this->renderProfilePage($request, $user); $tab = $this->normalizeProfileTab($request->query('tab'));
if ($tab !== null) {
return $this->redirectToProfileTab($request, (string) $user->username, $tab);
}
return $this->renderProfilePage($request, $user, 'Profile/ProfileShow', false, 'posts');
} }
public function showGalleryByUsername(Request $request, string $username) public function showGalleryByUsername(Request $request, string $username)
@@ -111,6 +128,45 @@ class ProfileController extends Controller
return $this->renderProfilePage($request, $user, 'Profile/ProfileGallery', true); return $this->renderProfilePage($request, $user, 'Profile/ProfileGallery', true);
} }
public function showTabByUsername(Request $request, string $username, string $tab)
{
$normalized = UsernamePolicy::normalize($username);
$user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
$normalizedTab = $this->normalizeProfileTab($tab);
if ($normalizedTab === null) {
abort(404);
}
if (! $user) {
$redirect = DB::table('username_redirects')
->whereRaw('LOWER(old_username) = ?', [$normalized])
->value('new_username');
if ($redirect) {
return redirect()->route('profile.tab', [
'username' => strtolower((string) $redirect),
'tab' => $normalizedTab,
], 301);
}
abort(404);
}
if ($username !== strtolower((string) $user->username)) {
return redirect()->route('profile.tab', [
'username' => strtolower((string) $user->username),
'tab' => $normalizedTab,
], 301);
}
if ($request->query->has('tab')) {
return $this->redirectToProfileTab($request, (string) $user->username, $normalizedTab);
}
return $this->renderProfilePage($request, $user, 'Profile/ProfileShow', false, $normalizedTab);
}
public function legacyById(Request $request, int $id, ?string $username = null) public function legacyById(Request $request, int $id, ?string $username = null)
{ {
$user = User::query()->findOrFail($id); $user = User::query()->findOrFail($id);
@@ -836,7 +892,13 @@ class ProfileController extends Controller
return Redirect::route('dashboard.profile')->with('status', 'password-updated'); return Redirect::route('dashboard.profile')->with('status', 'password-updated');
} }
private function renderProfilePage(Request $request, User $user, string $component = 'Profile/ProfileShow', bool $galleryOnly = false) private function renderProfilePage(
Request $request,
User $user,
string $component = 'Profile/ProfileShow',
bool $galleryOnly = false,
?string $initialTab = null,
)
{ {
$isOwner = Auth::check() && Auth::id() === $user->id; $isOwner = Auth::check() && Auth::id() === $user->id;
$viewer = Auth::user(); $viewer = Auth::user();
@@ -1088,8 +1150,19 @@ class ProfileController extends Controller
$usernameSlug = strtolower((string) ($user->username ?? '')); $usernameSlug = strtolower((string) ($user->username ?? ''));
$canonical = url('/@' . $usernameSlug); $canonical = url('/@' . $usernameSlug);
$galleryUrl = url('/@' . $usernameSlug . '/gallery'); $galleryUrl = url('/@' . $usernameSlug . '/gallery');
$profileTabUrls = collect(self::PROFILE_TABS)
->mapWithKeys(fn (string $tab) => [$tab => url('/@' . $usernameSlug . '/' . $tab)])
->all();
$achievementSummary = $this->achievements->summary((int) $user->id); $achievementSummary = $this->achievements->summary((int) $user->id);
$leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id); $leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id);
$resolvedInitialTab = $this->normalizeProfileTab($initialTab);
$isTabLanding = ! $galleryOnly && $resolvedInitialTab !== null;
$activeProfileUrl = $resolvedInitialTab !== null
? ($profileTabUrls[$resolvedInitialTab] ?? $canonical)
: $canonical;
$tabMetaLabel = $resolvedInitialTab !== null
? ucfirst($resolvedInitialTab)
: null;
return Inertia::render($component, [ return Inertia::render($component, [
'user' => [ 'user' => [
@@ -1133,20 +1206,51 @@ class ProfileController extends Controller
'countryName' => $countryName, 'countryName' => $countryName,
'isOwner' => $isOwner, 'isOwner' => $isOwner,
'auth' => $authData, 'auth' => $authData,
'initialTab' => $resolvedInitialTab,
'profileUrl' => $canonical, 'profileUrl' => $canonical,
'galleryUrl' => $galleryUrl, 'galleryUrl' => $galleryUrl,
'profileTabUrls' => $profileTabUrls,
])->withViewData([ ])->withViewData([
'page_title' => $galleryOnly 'page_title' => $galleryOnly
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase') ? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase'), : ($isTabLanding
'page_canonical' => $galleryOnly ? $galleryUrl : $canonical, ? (($user->username ?? $user->name ?? 'User') . ' ' . $tabMetaLabel . ' on Skinbase')
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase')),
'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl,
'page_meta_description' => $galleryOnly 'page_meta_description' => $galleryOnly
? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.') ? ('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.'), : ($isTabLanding
? ('Explore the ' . strtolower((string) $tabMetaLabel) . ' section for ' . ($user->username ?? $user->name) . ' on Skinbase.')
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.')),
'og_image' => $avatarUrl, 'og_image' => $avatarUrl,
]); ]);
} }
private function normalizeProfileTab(mixed $tab): ?string
{
if (! is_string($tab)) {
return null;
}
$normalized = strtolower(trim($tab));
return in_array($normalized, self::PROFILE_TABS, true) ? $normalized : null;
}
private function redirectToProfileTab(Request $request, string $username, string $tab): RedirectResponse
{
$baseUrl = url('/@' . strtolower($username) . '/' . $tab);
$query = $request->query();
unset($query['tab']);
if ($query !== []) {
$baseUrl .= '?' . http_build_query($query);
}
return redirect()->to($baseUrl, 301);
}
private function resolveFavouriteTable(): ?string private function resolveFavouriteTable(): ?string
{ {
foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) { foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) {
@@ -1164,6 +1268,9 @@ class ProfileController extends Controller
private function mapArtworkCardPayload(Artwork $art): array private function mapArtworkCardPayload(Artwork $art): array
{ {
$present = ThumbnailPresenter::present($art, 'md'); $present = ThumbnailPresenter::present($art, 'md');
$category = $art->categories->first();
$contentType = $category?->contentType;
$stats = $art->stats;
return [ return [
'id' => $art->id, 'id' => $art->id,
@@ -1178,6 +1285,13 @@ class ProfileController extends Controller
'user_id' => $art->user_id, 'user_id' => $art->user_id,
'author_level' => (int) ($art->user?->level ?? 1), 'author_level' => (int) ($art->user?->level ?? 1),
'author_rank' => (string) ($art->user?->rank ?? 'Newbie'), 'author_rank' => (string) ($art->user?->rank ?? 'Newbie'),
'content_type' => $contentType?->name,
'content_type_slug' => $contentType?->slug,
'category' => $category?->name,
'category_slug' => $category?->slug,
'views' => (int) ($stats?->views ?? $art->view_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0),
'width' => $art->width, 'width' => $art->width,
'height' => $art->height, 'height' => $art->height,
]; ];

View File

@@ -8,8 +8,11 @@ use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkResource; use App\Http\Resources\ArtworkResource;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\ArtworkComment; use App\Models\ArtworkComment;
use App\Services\ContentSanitizer;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
use App\Services\ErrorSuggestionService; use App\Services\ErrorSuggestionService;
use App\Support\AvatarUrl;
use Illuminate\Support\Carbon;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
@@ -167,23 +170,38 @@ final class ArtworkPageController extends Controller
// Recursive helper to format a comment and its nested replies // Recursive helper to format a comment and its nested replies
$formatComment = null; $formatComment = null;
$formatComment = function(ArtworkComment $c) use (&$formatComment) { $formatComment = function (ArtworkComment $c) use (&$formatComment): array {
$replies = $c->relationLoaded('approvedReplies') ? $c->approvedReplies : collect(); $replies = $c->relationLoaded('approvedReplies') ? $c->approvedReplies : collect();
$user = $c->user;
$userId = (int) ($c->user_id ?? 0);
$avatarHash = $user?->profile?->avatar_hash ?? null;
$canPublishLinks = (int) ($user?->level ?? 1) > 1 && strtolower((string) ($user?->rank ?? 'Newbie')) !== 'newbie';
$rawContent = (string) ($c->raw_content ?? $c->content ?? '');
$renderedContent = $c->rendered_content;
if (! is_string($renderedContent) || trim($renderedContent) === '') {
$renderedContent = $rawContent !== ''
? ContentSanitizer::render($rawContent)
: nl2br(e(strip_tags((string) ($c->content ?? ''))));
}
return [ return [
'id' => $c->id, 'id' => $c->id,
'parent_id' => $c->parent_id, 'parent_id' => $c->parent_id,
'content' => html_entity_decode((string) $c->content, ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'content' => html_entity_decode((string) $c->content, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'raw_content' => $c->raw_content ?? $c->content, 'raw_content' => $c->raw_content ?? $c->content,
'rendered_content' => $c->rendered_content, 'rendered_content' => ContentSanitizer::sanitizeRenderedHtml($renderedContent, $canPublishLinks),
'created_at' => $c->created_at?->toIsoString(), 'created_at' => $c->created_at?->toIso8601String(),
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
'user' => [ 'user' => [
'id' => $c->user?->id, 'id' => $userId,
'name' => $c->user?->name, 'name' => $user?->name,
'username' => $c->user?->username, 'username' => $user?->username,
'display' => $c->user?->username ?? $c->user?->name ?? 'User', 'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $c->user?->username ? '/@' . $c->user->username : null, 'profile_url' => $user?->username ? '/@' . $user->username : ($userId > 0 ? '/profile/' . $userId : null),
'avatar_url' => $c->user?->profile?->avatar_url, 'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'),
], ],
'replies' => $replies->map($formatComment)->values()->all(), 'replies' => $replies->map($formatComment)->values()->all(),
]; ];

View File

@@ -17,4 +17,4 @@ class ManageConversationParticipantRequest extends FormRequest
'user_id' => 'required|integer|exists:users,id', 'user_id' => 'required|integer|exists:users,id',
]; ];
} }
} }

View File

@@ -17,4 +17,4 @@ class RenameConversationRequest extends FormRequest
'title' => 'required|string|max:120', 'title' => 'required|string|max:120',
]; ];
} }
} }

View File

@@ -23,4 +23,4 @@ class StoreConversationRequest extends FormRequest
'client_temp_id' => 'nullable|string|max:120', 'client_temp_id' => 'nullable|string|max:120',
]; ];
} }
} }

View File

@@ -21,4 +21,4 @@ class StoreMessageRequest extends FormRequest
'reply_to_message_id' => 'nullable|integer|exists:messages,id', 'reply_to_message_id' => 'nullable|integer|exists:messages,id',
]; ];
} }
} }

View File

@@ -17,4 +17,4 @@ class ToggleMessageReactionRequest extends FormRequest
'reaction' => 'required|string|max:32', 'reaction' => 'required|string|max:32',
]; ];
} }
} }

View File

@@ -17,4 +17,4 @@ class UpdateMessageRequest extends FormRequest
'body' => 'required|string|max:5000', 'body' => 'required|string|max:5000',
]; ];
} }
} }

View File

@@ -1,6 +1,7 @@
<?php <?php
namespace App\Http\Resources; namespace App\Http\Resources;
use App\Services\ContentSanitizer;
use App\Services\ThumbnailPresenter; use App\Services\ThumbnailPresenter;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -100,6 +101,7 @@ class ArtworkResource extends JsonResource
'slug' => (string) $this->slug, 'slug' => (string) $this->slug,
'title' => $decode($this->title), 'title' => $decode($this->title),
'description' => $decode($this->description), 'description' => $decode($this->description),
'description_html' => $this->renderDescriptionHtml(),
'dimensions' => [ 'dimensions' => [
'width' => (int) ($this->width ?? 0), 'width' => (int) ($this->width ?? 0),
'height' => (int) ($this->height ?? 0), 'height' => (int) ($this->height ?? 0),
@@ -123,6 +125,8 @@ class ArtworkResource extends JsonResource
'username' => (string) ($this->user?->username ?? ''), 'username' => (string) ($this->user?->username ?? ''),
'profile_url' => $this->user?->username ? '/@' . $this->user->username : null, 'profile_url' => $this->user?->username ? '/@' . $this->user->username : null,
'avatar_url' => $this->user?->profile?->avatar_url, 'avatar_url' => $this->user?->profile?->avatar_url,
'level' => (int) ($this->user?->level ?? 1),
'rank' => (string) ($this->user?->rank ?? 'Newbie'),
'followers_count' => $followerCount, 'followers_count' => $followerCount,
], ],
'viewer' => [ 'viewer' => [
@@ -168,4 +172,27 @@ class ArtworkResource extends JsonResource
])->values(), ])->values(),
]; ];
} }
private function renderDescriptionHtml(): string
{
$rawDescription = (string) ($this->description ?? '');
if (trim($rawDescription) === '') {
return '';
}
if (! $this->authorCanPublishLinks()) {
return nl2br(e(ContentSanitizer::stripToPlain($rawDescription)));
}
return ContentSanitizer::render($rawDescription);
}
private function authorCanPublishLinks(): bool
{
$level = (int) ($this->user?->level ?? 1);
$rank = strtolower((string) ($this->user?->rank ?? 'Newbie'));
return $level > 1 && $rank !== 'newbie';
}
} }

View File

@@ -7,6 +7,7 @@ namespace App\Jobs;
use App\Models\Artwork; use App\Models\Artwork;
use App\Models\ArtworkEmbedding; use App\Models\ArtworkEmbedding;
use App\Services\Vision\ArtworkEmbeddingClient; use App\Services\Vision\ArtworkEmbeddingClient;
use App\Services\Vision\ArtworkVisionImageUrl;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@@ -41,7 +42,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
return [2, 10, 30]; return [2, 10, 30];
} }
public function handle(ArtworkEmbeddingClient $client): void public function handle(ArtworkEmbeddingClient $client, ArtworkVisionImageUrl $imageUrlBuilder): void
{ {
if (! (bool) config('recommendations.embedding.enabled', true)) { if (! (bool) config('recommendations.embedding.enabled', true)) {
return; return;
@@ -79,7 +80,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
} }
try { try {
$imageUrl = $this->buildImageUrl($sourceHash); $imageUrl = $imageUrlBuilder->fromHash($sourceHash, (string) ($artwork->thumb_ext ?: 'webp'));
if ($imageUrl === null) { if ($imageUrl === null) {
return; return;
} }
@@ -134,21 +135,6 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
return array_map(static fn (float $value): float => $value / $norm, $vector); return array_map(static fn (float $value): float => $value / $norm, $vector);
} }
private function buildImageUrl(string $hash): ?string
{
$base = rtrim((string) config('cdn.files_url', ''), '/');
if ($base === '') {
return null;
}
$variant = (string) config('vision.image_variant', 'md');
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));
$clean = str_pad($clean, 6, '0');
$segments = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00'];
return $base . '/img/' . implode('/', $segments) . '/' . $variant . '.webp';
}
private function lockKey(int $artworkId, string $model, string $version): string private function lockKey(int $artworkId, string $model, string $version): string
{ {
return 'artwork-embedding:lock:' . $artworkId . ':' . $model . ':' . $version; return 'artwork-embedding:lock:' . $artworkId . ':' . $model . ':' . $version;

View File

@@ -31,4 +31,4 @@ class MessageRead extends Model
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
} }

View File

@@ -40,7 +40,7 @@ class ArtworkSharedNotification extends Notification implements ShouldQueue
'sharer_name' => $this->sharer->name, 'sharer_name' => $this->sharer->name,
'sharer_username' => $this->sharer->username, 'sharer_username' => $this->sharer->username,
'message' => $this->sharer->name . ' shared your artwork "' . $this->artwork->title . '"', 'message' => $this->sharer->name . ' shared your artwork "' . $this->artwork->title . '"',
'url' => "/@{$this->sharer->username}?tab=posts", 'url' => "/@{$this->sharer->username}/posts",
]; ];
} }
} }

View File

@@ -39,7 +39,7 @@ class PostCommentedNotification extends Notification implements ShouldQueue
'commenter_name' => $this->commenter->name, 'commenter_name' => $this->commenter->name,
'commenter_username' => $this->commenter->username, 'commenter_username' => $this->commenter->username,
'message' => "{$this->commenter->name} commented on your post", 'message' => "{$this->commenter->name} commented on your post",
'url' => "/@{$this->post->user->username}?tab=posts", 'url' => "/@{$this->post->user->username}/posts",
]; ];
} }
} }

View File

@@ -44,4 +44,4 @@ class ConversationPolicy
->whereNull('left_at') ->whereNull('left_at')
->first(); ->first();
} }
} }

View File

@@ -26,4 +26,4 @@ class MessagePolicy
{ {
return $message->sender_id === $user->id || $user->isAdmin(); return $message->sender_id === $user->id || $user->isAdmin();
} }
} }

View File

@@ -290,6 +290,44 @@ class AppServiceProvider extends ServiceProvider
Limit::perMinute(120)->by('messages:react:ip:' . $request->ip()), Limit::perMinute(120)->by('messages:react:ip:' . $request->ip()),
]; ];
}); });
RateLimiter::for('messages-read', function (Request $request): array {
$userId = $request->user()?->id ?? 'guest';
return [
Limit::perMinute(120)->by('messages:read:user:' . $userId),
Limit::perMinute(240)->by('messages:read:ip:' . $request->ip()),
];
});
RateLimiter::for('messages-typing', function (Request $request): array {
$userId = $request->user()?->id ?? 'guest';
$conversationId = (int) $request->route('conversation_id');
return [
Limit::perMinute(90)->by('messages:typing:user:' . $userId . ':conv:' . $conversationId),
Limit::perMinute(180)->by('messages:typing:ip:' . $request->ip()),
];
});
RateLimiter::for('messages-recovery', function (Request $request): array {
$userId = $request->user()?->id ?? 'guest';
$conversationId = (int) $request->route('conversation_id');
return [
Limit::perMinute(30)->by('messages:recovery:user:' . $userId . ':conv:' . $conversationId),
Limit::perMinute(60)->by('messages:recovery:ip:' . $request->ip()),
];
});
RateLimiter::for('messages-presence', function (Request $request): array {
$userId = $request->user()?->id ?? 'guest';
return [
Limit::perMinute(180)->by('messages:presence:user:' . $userId),
Limit::perMinute(300)->by('messages:presence:ip:' . $request->ip()),
];
});
} }
private function configureDownloadRateLimiter(): void private function configureDownloadRateLimiter(): void

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
parent::boot();
// Horizon::routeSmsNotificationsTo('15556667777');
// Horizon::routeMailNotificationsTo('example@example.com');
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
}
/**
* Register the Horizon gate.
*
* This gate determines who can access Horizon in non-local environments.
*/
protected function gate(): void
{
Gate::define('viewHorizon', function ($user = null) {
return app()->environment('local')
|| (is_object($user) && method_exists($user, 'isAdmin') && $user->isAdmin());
});
}
}

View File

@@ -301,7 +301,8 @@ class ArtworkService
{ {
$query = Artwork::where('user_id', $userId) $query = Artwork::where('user_id', $userId)
->with([ ->with([
'user:id,name,username', 'user:id,name,username,level,rank',
'stats:artwork_id,views,downloads,favorites',
'categories' => function ($q) { 'categories' => function ($q) {
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') $q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']); ->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);

View File

@@ -72,6 +72,20 @@ class ContentSanitizer
return $html; return $html;
} }
/**
* Normalize previously rendered HTML for display-time policy changes.
* This is useful when stored HTML predates current link attributes or
* when display rules depend on the author rather than the raw content.
*/
public static function sanitizeRenderedHtml(?string $html, bool $allowLinks = true): string
{
if ($html === null || trim($html) === '') {
return '';
}
return static::sanitizeHtml($html, $allowLinks);
}
/** /**
* Strip ALL HTML from input, returning plain text with newlines preserved. * Strip ALL HTML from input, returning plain text with newlines preserved.
*/ */
@@ -190,7 +204,7 @@ class ContentSanitizer
* Whitelist-based HTML sanitizer. * Whitelist-based HTML sanitizer.
* Removes all tags not in ALLOWED_TAGS, and strips disallowed attributes. * Removes all tags not in ALLOWED_TAGS, and strips disallowed attributes.
*/ */
private static function sanitizeHtml(string $html): string private static function sanitizeHtml(string $html, bool $allowLinks = true): string
{ {
// Parse with DOMDocument // Parse with DOMDocument
$doc = new \DOMDocument('1.0', 'UTF-8'); $doc = new \DOMDocument('1.0', 'UTF-8');
@@ -202,7 +216,7 @@ class ContentSanitizer
); );
libxml_clear_errors(); libxml_clear_errors();
static::cleanNode($doc->getElementsByTagName('body')->item(0)); static::cleanNode($doc->getElementsByTagName('body')->item(0), $allowLinks);
// Serialize back, removing the wrapping html/body // Serialize back, removing the wrapping html/body
$body = $doc->getElementsByTagName('body')->item(0); $body = $doc->getElementsByTagName('body')->item(0);
@@ -218,13 +232,17 @@ class ContentSanitizer
/** /**
* Recursively clean a DOMNode strip forbidden tags/attributes. * Recursively clean a DOMNode strip forbidden tags/attributes.
*/ */
private static function cleanNode(\DOMNode $node): void private static function cleanNode(\DOMNode $node, bool $allowLinks = true): void
{ {
$toRemove = []; $toRemove = [];
$toUnwrap = []; $toUnwrap = [];
foreach ($node->childNodes as $child) { foreach ($node->childNodes as $child) {
if ($child->nodeType === XML_ELEMENT_NODE) { if ($child->nodeType === XML_ELEMENT_NODE) {
if (! $child instanceof \DOMElement) {
continue;
}
$tag = strtolower($child->nodeName); $tag = strtolower($child->nodeName);
if (! in_array($tag, self::ALLOWED_TAGS, true)) { if (! in_array($tag, self::ALLOWED_TAGS, true)) {
@@ -245,17 +263,22 @@ class ContentSanitizer
// Force external links to be safe // Force external links to be safe
if ($tag === 'a') { if ($tag === 'a') {
if (! $allowLinks) {
$toUnwrap[] = $child;
continue;
}
$href = $child->getAttribute('href'); $href = $child->getAttribute('href');
if ($href && ! static::isSafeUrl($href)) { if ($href && ! static::isSafeUrl($href)) {
$toUnwrap[] = $child; $toUnwrap[] = $child;
continue; continue;
} }
$child->setAttribute('rel', 'noopener noreferrer nofollow'); $child->setAttribute('rel', 'noopener noreferrer nofollow ugc');
$child->setAttribute('target', '_blank'); $child->setAttribute('target', '_blank');
} }
// Recurse // Recurse
static::cleanNode($child); static::cleanNode($child, $allowLinks);
} }
} }
} }

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Services\Messaging;
use App\Models\Conversation;
use App\Models\Message;
use App\Models\User;
use Illuminate\Support\Collection;
class ConversationDeltaService
{
public function __construct(
private readonly MessagingPayloadFactory $payloadFactory,
) {}
public function messagesAfter(Conversation $conversation, User $viewer, int $afterMessageId, ?int $limit = null): Collection
{
$maxMessages = max(1, (int) config('messaging.recovery.max_messages', 100));
$effectiveLimit = min($limit ?? $maxMessages, $maxMessages);
return Message::withTrashed()
->where('conversation_id', $conversation->id)
->where('id', '>', $afterMessageId)
->with(['sender:id,username,name', 'reactions', 'attachments'])
->orderBy('id')
->limit($effectiveLimit)
->get()
->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $viewer->id))
->values();
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Services\Messaging;
use App\Events\ConversationUpdated;
use App\Events\MessageRead;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class ConversationReadService
{
public function __construct(
private readonly ConversationStateService $conversationState,
) {}
public function markConversationRead(Conversation $conversation, User $user, ?int $messageId = null): ConversationParticipant
{
/** @var ConversationParticipant $participant */
$participant = ConversationParticipant::query()
->where('conversation_id', $conversation->id)
->where('user_id', $user->id)
->whereNull('left_at')
->firstOrFail();
$lastReadableMessage = Message::query()
->where('conversation_id', $conversation->id)
->whereNull('deleted_at')
->where('sender_id', '!=', $user->id)
->when($messageId, fn ($query) => $query->where('id', '<=', $messageId))
->orderByDesc('id')
->first();
$readAt = now();
$participant->forceFill([
'last_read_at' => $readAt,
'last_read_message_id' => $lastReadableMessage?->id,
])->save();
if ($lastReadableMessage) {
$messageReads = Message::query()
->select(['id'])
->where('conversation_id', $conversation->id)
->whereNull('deleted_at')
->where('sender_id', '!=', $user->id)
->where('id', '<=', $lastReadableMessage->id)
->get()
->map(fn (Message $message) => [
'message_id' => $message->id,
'user_id' => $user->id,
'read_at' => $readAt,
])
->all();
if (! empty($messageReads)) {
DB::table('message_reads')->upsert($messageReads, ['message_id', 'user_id'], ['read_at']);
}
}
$participantIds = $this->conversationState->activeParticipantIds($conversation);
$this->conversationState->touchConversationCachesForUsers($participantIds);
DB::afterCommit(function () use ($conversation, $participant, $user, $participantIds): void {
event(new MessageRead($conversation, $participant, $user));
foreach ($participantIds as $participantId) {
event(new ConversationUpdated($participantId, $conversation, 'message.read'));
}
});
return $participant->fresh(['user']);
}
}

View File

@@ -2,14 +2,9 @@
namespace App\Services\Messaging; namespace App\Services\Messaging;
use App\Events\ConversationUpdated;
use App\Events\MessageRead;
use App\Models\Conversation; use App\Models\Conversation;
use App\Models\ConversationParticipant; use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class ConversationStateService class ConversationStateService
{ {
@@ -37,62 +32,4 @@ class ConversationStateService
Cache::increment($versionKey); Cache::increment($versionKey);
} }
} }
}
public function markConversationRead(Conversation $conversation, User $user, ?int $messageId = null): ConversationParticipant
{
/** @var ConversationParticipant $participant */
$participant = ConversationParticipant::query()
->where('conversation_id', $conversation->id)
->where('user_id', $user->id)
->whereNull('left_at')
->firstOrFail();
$lastReadableMessage = Message::query()
->where('conversation_id', $conversation->id)
->whereNull('deleted_at')
->where('sender_id', '!=', $user->id)
->when($messageId, fn ($query) => $query->where('id', '<=', $messageId))
->orderByDesc('id')
->first();
$readAt = now();
$participant->update([
'last_read_at' => $readAt,
'last_read_message_id' => $lastReadableMessage?->id,
]);
if ($lastReadableMessage) {
$messageReads = Message::query()
->select(['id'])
->where('conversation_id', $conversation->id)
->whereNull('deleted_at')
->where('sender_id', '!=', $user->id)
->where('id', '<=', $lastReadableMessage->id)
->get()
->map(fn (Message $message) => [
'message_id' => $message->id,
'user_id' => $user->id,
'read_at' => $readAt,
])
->all();
if (! empty($messageReads)) {
DB::table('message_reads')->upsert($messageReads, ['message_id', 'user_id'], ['read_at']);
}
}
$participantIds = $this->activeParticipantIds($conversation);
$this->touchConversationCachesForUsers($participantIds);
DB::afterCommit(function () use ($conversation, $participant, $user): void {
event(new MessageRead($conversation, $participant, $user));
foreach ($this->activeParticipantIds($conversation) as $participantId) {
event(new ConversationUpdated($participantId, $conversation, 'message.read'));
}
});
return $participant->fresh(['user']);
}
}

View File

@@ -13,6 +13,10 @@ use Illuminate\Support\Str;
class MessageNotificationService class MessageNotificationService
{ {
public function __construct(
private readonly MessagingPresenceService $presence,
) {}
public function notifyNewMessage(Conversation $conversation, Message $message, User $sender): void public function notifyNewMessage(Conversation $conversation, Message $message, User $sender): void
{ {
if (! DB::getSchemaBuilder()->hasTable('notifications')) { if (! DB::getSchemaBuilder()->hasTable('notifications')) {
@@ -36,6 +40,13 @@ class MessageNotificationService
->whereIn('id', $recipientIds) ->whereIn('id', $recipientIds)
->get() ->get()
->filter(fn (User $recipient) => $recipient->allowsMessagesFrom($sender)) ->filter(fn (User $recipient) => $recipient->allowsMessagesFrom($sender))
->filter(function (User $recipient): bool {
if (! (bool) config('messaging.notifications.offline_fallback_only', true)) {
return true;
}
return ! $this->presence->isUserOnline((int) $recipient->id);
})
->pluck('id') ->pluck('id')
->map(fn ($id) => (int) $id) ->map(fn ($id) => (int) $id)
->values() ->values()

View File

@@ -56,7 +56,7 @@ class MessagingPayloadFactory
'title' => $conversation->title, 'title' => $conversation->title,
'is_active' => (bool) ($conversation->is_active ?? true), 'is_active' => (bool) ($conversation->is_active ?? true),
'last_message_at' => optional($conversation->last_message_at)?->toIso8601String(), 'last_message_at' => optional($conversation->last_message_at)?->toIso8601String(),
'unread_count' => $conversation->unreadCountFor($viewerId), 'unread_count' => app(UnreadCounterService::class)->unreadCountForConversation($conversation, $viewerId),
'my_participant' => $myParticipant ? $this->participant($myParticipant) : null, 'my_participant' => $myParticipant ? $this->participant($myParticipant) : null,
'all_participants' => $conversation->allParticipants 'all_participants' => $conversation->allParticipants
->whereNull('left_at') ->whereNull('left_at')
@@ -149,4 +149,4 @@ class MessagingPayloadFactory
return $counts; return $counts;
} }
} }

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Services\Messaging;
use App\Models\User;
use Illuminate\Cache\Repository;
use Illuminate\Support\Facades\Cache;
class MessagingPresenceService
{
public function touch(User|int $user, ?int $conversationId = null): void
{
$userId = $user instanceof User ? (int) $user->id : (int) $user;
$store = $this->store();
$onlineKey = $this->onlineKey($userId);
$existing = $store->get($onlineKey, []);
$previousConversationId = (int) ($existing['conversation_id'] ?? 0) ?: null;
$onlineTtl = max(30, (int) config('messaging.presence.ttl_seconds', 90));
$conversationTtl = max(15, (int) config('messaging.presence.conversation_ttl_seconds', 45));
if ($previousConversationId && $previousConversationId !== $conversationId) {
$store->forget($this->conversationKey($previousConversationId, $userId));
}
$store->put($onlineKey, [
'conversation_id' => $conversationId,
'seen_at' => now()->toIso8601String(),
], now()->addSeconds($onlineTtl));
if ($conversationId) {
$store->put($this->conversationKey($conversationId, $userId), now()->toIso8601String(), now()->addSeconds($conversationTtl));
}
}
public function isUserOnline(int $userId): bool
{
return $this->store()->has($this->onlineKey($userId));
}
public function isViewingConversation(int $conversationId, int $userId): bool
{
return $this->store()->has($this->conversationKey($conversationId, $userId));
}
private function onlineKey(int $userId): string
{
return 'messages:presence:user:' . $userId;
}
private function conversationKey(int $conversationId, int $userId): string
{
return 'messages:presence:conversation:' . $conversationId . ':user:' . $userId;
}
private function store(): Repository
{
$store = (string) config('messaging.presence.cache_store', 'redis');
if ($store === 'redis' && ! class_exists('Redis')) {
return Cache::store();
}
try {
return Cache::store($store);
} catch (\Throwable) {
return Cache::store();
}
}
}

View File

@@ -123,4 +123,4 @@ class SendMessageAction
'created_at' => now(), 'created_at' => now(),
]); ]);
} }
} }

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Services\Messaging;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
class UnreadCounterService
{
public function applyUnreadCountSelect(Builder $query, User|int $user, string $participantAlias = 'cp_me'): Builder
{
$userId = $user instanceof User ? (int) $user->id : (int) $user;
return $query->addSelect([
'unread_count' => Message::query()
->selectRaw('count(*)')
->whereColumn('messages.conversation_id', 'conversations.id')
->where('messages.sender_id', '!=', $userId)
->whereNull('messages.deleted_at')
->where(function ($nested) use ($participantAlias) {
$nested->where(function ($group) use ($participantAlias) {
$group->whereNull($participantAlias . '.last_read_message_id')
->whereNull($participantAlias . '.last_read_at');
})->orWhereColumn('messages.id', '>', $participantAlias . '.last_read_message_id')
->orWhereColumn('messages.created_at', '>', $participantAlias . '.last_read_at');
}),
]);
}
public function unreadCountForConversation(Conversation $conversation, User|int $user): int
{
$userId = $user instanceof User ? (int) $user->id : (int) $user;
$participant = ConversationParticipant::query()
->where('conversation_id', $conversation->id)
->where('user_id', $userId)
->whereNull('left_at')
->first();
if (! $participant) {
return 0;
}
return $this->unreadCountForParticipant($participant);
}
public function unreadCountForParticipant(ConversationParticipant $participant): int
{
$query = Message::query()
->where('conversation_id', $participant->conversation_id)
->where('sender_id', '!=', $participant->user_id)
->whereNull('deleted_at');
if ($participant->last_read_message_id) {
$query->where('id', '>', $participant->last_read_message_id);
} elseif ($participant->last_read_at) {
$query->where('created_at', '>', $participant->last_read_at);
}
return (int) $query->count();
}
public function totalUnreadForUser(User|int $user): int
{
$userId = $user instanceof User ? (int) $user->id : (int) $user;
return (int) Conversation::query()
->select('conversations.id')
->join('conversation_participants as cp_me', function ($join) use ($userId) {
$join->on('cp_me.conversation_id', '=', 'conversations.id')
->where('cp_me.user_id', '=', $userId)
->whereNull('cp_me.left_at');
})
->where('conversations.is_active', true)
->get()
->sum(fn (Conversation $conversation) => $this->unreadCountForConversation($conversation, $userId));
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Services\Vision;
use App\Models\Artwork;
use App\Services\ThumbnailService;
final class ArtworkVisionImageUrl
{
public function fromArtwork(Artwork $artwork): ?string
{
return $this->fromHash(
(string) ($artwork->hash ?? ''),
(string) ($artwork->thumb_ext ?: 'webp')
);
}
public function fromHash(?string $hash, ?string $ext = 'webp', string $size = 'md'): ?string
{
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) $hash));
if ($clean === '') {
return null;
}
return ThumbnailService::fromHash($clean, $ext, $size);
}
}

View File

@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace App\Services\Vision;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
final class VectorGatewayClient
{
public function isConfigured(): bool
{
return (bool) config('vision.vector_gateway.enabled', true)
&& $this->baseUrl() !== ''
&& $this->apiKey() !== '';
}
public function upsertByUrl(string $imageUrl, int|string $id, array $metadata = []): array
{
$response = $this->postJson(
$this->url((string) config('vision.vector_gateway.upsert_endpoint', '/vectors/upsert')),
[
'url' => $imageUrl,
'id' => (string) $id,
'metadata' => $metadata,
]
);
if ($response->failed()) {
throw new RuntimeException($this->failureMessage('Vector upsert', $response));
}
$json = $response->json();
return is_array($json) ? $json : [];
}
/**
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
*/
public function searchByUrl(string $imageUrl, int $limit = 5): array
{
$response = $this->postJson(
$this->url((string) config('vision.vector_gateway.search_endpoint', '/vectors/search')),
[
'url' => $imageUrl,
'limit' => max(1, $limit),
]
);
if ($response->failed()) {
throw new RuntimeException($this->failureMessage('Vector search', $response));
}
return $this->extractMatches($response->json());
}
public function deleteByIds(array $ids): array
{
$response = $this->postJson(
$this->url((string) config('vision.vector_gateway.delete_endpoint', '/vectors/delete')),
[
'ids' => array_values(array_map(static fn (int|string $id): string => (string) $id, $ids)),
]
);
if ($response->failed()) {
throw new RuntimeException($this->failureMessage('Vector delete', $response));
}
$json = $response->json();
return is_array($json) ? $json : [];
}
private function request(): PendingRequest
{
if (! $this->isConfigured()) {
throw new RuntimeException('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
}
return Http::acceptJson()
->withHeaders([
'X-API-Key' => $this->apiKey(),
])
->connectTimeout(max(1, (int) config('vision.vector_gateway.connect_timeout_seconds', 5)))
->timeout(max(1, (int) config('vision.vector_gateway.timeout_seconds', 20)))
->retry(
max(0, (int) config('vision.vector_gateway.retries', 1)),
max(0, (int) config('vision.vector_gateway.retry_delay_ms', 250)),
throw: false,
);
}
/**
* @param array<string, mixed> $payload
*/
private function postJson(string $url, array $payload): Response
{
$response = $this->request()->post($url, $payload);
if (! $response instanceof Response) {
throw new RuntimeException('Vector gateway request did not return an HTTP response.');
}
return $response;
}
private function baseUrl(): string
{
return rtrim((string) config('vision.vector_gateway.base_url', ''), '/');
}
private function apiKey(): string
{
return trim((string) config('vision.vector_gateway.api_key', ''));
}
private function url(string $path): string
{
return $this->baseUrl() . '/' . ltrim($path, '/');
}
private function failureMessage(string $operation, Response $response): string
{
$body = trim($response->body());
if ($body === '') {
return $operation . ' failed with HTTP ' . $response->status() . '.';
}
return $operation . ' failed with HTTP ' . $response->status() . ': ' . $body;
}
/**
* @param mixed $json
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
*/
private function extractMatches(mixed $json): array
{
$candidates = [];
if (is_array($json)) {
$candidates = $this->extractCandidateRows($json);
}
$results = [];
foreach ($candidates as $candidate) {
if (! is_array($candidate)) {
continue;
}
$id = $candidate['id']
?? $candidate['point_id']
?? $candidate['payload']['id']
?? $candidate['metadata']['id']
?? null;
if (! is_int($id) && ! is_string($id)) {
continue;
}
$score = $candidate['score']
?? $candidate['similarity']
?? $candidate['distance']
?? 0.0;
$metadata = $candidate['metadata'] ?? $candidate['payload'] ?? [];
if (! is_array($metadata)) {
$metadata = [];
}
$results[] = [
'id' => $id,
'score' => (float) $score,
'metadata' => $metadata,
];
}
return $results;
}
/**
* @param array<mixed> $json
* @return array<int, mixed>
*/
private function extractCandidateRows(array $json): array
{
$keys = ['results', 'matches', 'points', 'data'];
foreach ($keys as $key) {
if (! isset($json[$key]) || ! is_array($json[$key])) {
continue;
}
$value = $json[$key];
if (array_is_list($value)) {
return $value;
}
foreach (['results', 'matches', 'points', 'items'] as $nestedKey) {
if (isset($value[$nestedKey]) && is_array($value[$nestedKey]) && array_is_list($value[$nestedKey])) {
return $value[$nestedKey];
}
}
}
return array_is_list($json) ? $json : [];
}
}

View File

@@ -3,9 +3,10 @@
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class, App\Providers\AuthServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
Klevze\ControlPanel\ServiceProvider::class, Klevze\ControlPanel\ServiceProvider::class,
cPad\Plugins\Artworks\ServiceProvider::class, cPad\Plugins\Artworks\ServiceProvider::class,
cPad\Plugins\News\ServiceProvider::class,
cPad\Plugins\Forum\ServiceProvider::class, cPad\Plugins\Forum\ServiceProvider::class,
cPad\Plugins\News\ServiceProvider::class,
cPad\Plugins\Site\ServiceProvider::class, cPad\Plugins\Site\ServiceProvider::class,
]; ];

View File

@@ -17,6 +17,7 @@
"intervention/image": "^3.11", "intervention/image": "^3.11",
"jenssegers/agent": "*", "jenssegers/agent": "*",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/horizon": "^5.45",
"laravel/reverb": "^1.0", "laravel/reverb": "^1.0",
"laravel/scout": "^10.24", "laravel/scout": "^10.24",
"laravel/socialite": "^5.24", "laravel/socialite": "^5.24",

141
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "e1ededa537b256c2936370d7e28a4bd5", "content-hash": "7310d1d07635e290193ccbe4539b1397",
"packages": [ "packages": [
{ {
"name": "alexusmai/laravel-file-manager", "name": "alexusmai/laravel-file-manager",
@@ -2209,6 +2209,86 @@
}, },
"time": "2026-02-24T14:35:15+00:00" "time": "2026-02-24T14:35:15+00:00"
}, },
{
"name": "laravel/horizon",
"version": "v5.45.4",
"source": {
"type": "git",
"url": "https://github.com/laravel/horizon.git",
"reference": "b2b32e3f6013081e0176307e9081cd085f0ad4d6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/horizon/zipball/b2b32e3f6013081e0176307e9081cd085f0ad4d6",
"reference": "b2b32e3f6013081e0176307e9081cd085f0ad4d6",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-pcntl": "*",
"ext-posix": "*",
"illuminate/contracts": "^9.21|^10.0|^11.0|^12.0|^13.0",
"illuminate/queue": "^9.21|^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^9.21|^10.0|^11.0|^12.0|^13.0",
"laravel/sentinel": "^1.0",
"nesbot/carbon": "^2.17|^3.0",
"php": "^8.0",
"ramsey/uuid": "^4.0",
"symfony/console": "^6.0|^7.0|^8.0",
"symfony/error-handler": "^6.0|^7.0|^8.0",
"symfony/polyfill-php83": "^1.28",
"symfony/process": "^6.0|^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "^1.0",
"orchestra/testbench": "^7.56|^8.37|^9.16|^10.9|^11.0",
"phpstan/phpstan": "^1.10|^2.0",
"predis/predis": "^1.1|^2.0|^3.0"
},
"suggest": {
"ext-redis": "Required to use the Redis PHP driver.",
"predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0|^3.0)."
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Horizon": "Laravel\\Horizon\\Horizon"
},
"providers": [
"Laravel\\Horizon\\HorizonServiceProvider"
]
},
"branch-alias": {
"dev-master": "6.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Horizon\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Dashboard and code-driven configuration for Laravel queues.",
"keywords": [
"laravel",
"queue"
],
"support": {
"issues": "https://github.com/laravel/horizon/issues",
"source": "https://github.com/laravel/horizon/tree/v5.45.4"
},
"time": "2026-03-18T14:14:59+00:00"
},
{ {
"name": "laravel/prompts", "name": "laravel/prompts",
"version": "v0.3.13", "version": "v0.3.13",
@@ -2427,6 +2507,65 @@
}, },
"time": "2026-02-10T18:44:39+00:00" "time": "2026-02-10T18:44:39+00:00"
}, },
{
"name": "laravel/sentinel",
"version": "v1.0.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/sentinel.git",
"reference": "7a98db53e0d9d6f61387f3141c07477f97425603"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sentinel/zipball/7a98db53e0d9d6f61387f3141c07477f97425603",
"reference": "7a98db53e0d9d6f61387f3141c07477f97425603",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/container": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0"
},
"require-dev": {
"laravel/pint": "^1.27",
"orchestra/testbench": "^6.47.1|^7.56|^8.37|^9.16|^10.9|^11.0",
"phpstan/phpstan": "^2.1.33"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Sentinel\\SentinelServiceProvider"
]
},
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Sentinel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Mior Muhammad Zaki",
"email": "mior@laravel.com"
}
],
"support": {
"source": "https://github.com/laravel/sentinel/tree/v1.0.1"
},
"time": "2026-02-12T13:32:54+00:00"
},
{ {
"name": "laravel/serializable-closure", "name": "laravel/serializable-closure",
"version": "v2.0.10", "version": "v2.0.10",

277
config/horizon.php Normal file
View File

@@ -0,0 +1,277 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Horizon Name
|--------------------------------------------------------------------------
|
| This name appears in notifications and in the Horizon UI. Unique names
| can be useful while running multiple instances of Horizon within an
| application, allowing you to identify the Horizon you're viewing.
|
*/
'name' => env('HORIZON_NAME'),
/*
|--------------------------------------------------------------------------
| Horizon Domain
|--------------------------------------------------------------------------
|
| This is the subdomain where Horizon will be accessible from. If this
| setting is null, Horizon will reside under the same domain as the
| application. Otherwise, this value will serve as the subdomain.
|
*/
'domain' => env('HORIZON_DOMAIN'),
/*
|--------------------------------------------------------------------------
| Horizon Path
|--------------------------------------------------------------------------
|
| This is the URI path where Horizon will be accessible from. Feel free
| to change this path to anything you like. Note that the URI will not
| affect the paths of its internal API that aren't exposed to users.
|
*/
'path' => env('HORIZON_PATH', 'horizon'),
/*
|--------------------------------------------------------------------------
| Horizon Redis Connection
|--------------------------------------------------------------------------
|
| This is the name of the Redis connection where Horizon will store the
| meta information required for it to function. It includes the list
| of supervisors, failed jobs, job metrics, and other information.
|
*/
'use' => 'default',
/*
|--------------------------------------------------------------------------
| Horizon Redis Prefix
|--------------------------------------------------------------------------
|
| This prefix will be used when storing all Horizon data in Redis. You
| may modify the prefix when you are running multiple installations
| of Horizon on the same server so that they don't have problems.
|
*/
'prefix' => env(
'HORIZON_PREFIX',
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
),
/*
|--------------------------------------------------------------------------
| Horizon Route Middleware
|--------------------------------------------------------------------------
|
| These middleware will get attached onto each Horizon route, giving you
| the chance to add your own middleware to this list or change any of
| the existing middleware. Or, you can simply stick with this list.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Queue Wait Time Thresholds
|--------------------------------------------------------------------------
|
| This option allows you to configure when the LongWaitDetected event
| will be fired. Every connection / queue combination may have its
| own, unique threshold (in seconds) before this event is fired.
|
*/
'waits' => [
'redis:broadcasts' => 15,
'redis:default' => 60,
'redis:notifications' => 90,
],
/*
|--------------------------------------------------------------------------
| Job Trimming Times
|--------------------------------------------------------------------------
|
| Here you can configure for how long (in minutes) you desire Horizon to
| persist the recent and failed jobs. Typically, recent jobs are kept
| for one hour while all failed jobs are stored for an entire week.
|
*/
'trim' => [
'recent' => 60,
'pending' => 60,
'completed' => 60,
'recent_failed' => 10080,
'failed' => 10080,
'monitored' => 10080,
],
/*
|--------------------------------------------------------------------------
| Silenced Jobs
|--------------------------------------------------------------------------
|
| Silencing a job will instruct Horizon to not place the job in the list
| of completed jobs within the Horizon dashboard. This setting may be
| used to fully remove any noisy jobs from the completed jobs list.
|
*/
'silenced' => [
// App\Jobs\ExampleJob::class,
],
'silenced_tags' => [
// 'notifications',
],
/*
|--------------------------------------------------------------------------
| Metrics
|--------------------------------------------------------------------------
|
| Here you can configure how many snapshots should be kept to display in
| the metrics graph. This will get used in combination with Horizon's
| `horizon:snapshot` schedule to define how long to retain metrics.
|
*/
'metrics' => [
'trim_snapshots' => [
'job' => 24,
'queue' => 24,
],
],
/*
|--------------------------------------------------------------------------
| Fast Termination
|--------------------------------------------------------------------------
|
| When this option is enabled, Horizon's "terminate" command will not
| wait on all of the workers to terminate unless the --wait option
| is provided. Fast termination can shorten deployment delay by
| allowing a new instance of Horizon to start while the last
| instance will continue to terminate each of its workers.
|
*/
'fast_termination' => false,
/*
|--------------------------------------------------------------------------
| Memory Limit (MB)
|--------------------------------------------------------------------------
|
| This value describes the maximum amount of memory the Horizon master
| supervisor may consume before it is terminated and restarted. For
| configuring these limits on your workers, see the next section.
|
*/
'memory_limit' => 64,
/*
|--------------------------------------------------------------------------
| Queue Worker Configuration
|--------------------------------------------------------------------------
|
| Here you may define the queue worker settings used by your application
| in all environments. These supervisors and settings handle all your
| queued jobs and will be provisioned by Horizon during deployment.
|
*/
'defaults' => [
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 1,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 128,
'tries' => 1,
'timeout' => 60,
'nice' => 0,
],
'supervisor-messaging' => [
'connection' => 'redis',
'queue' => ['broadcasts', 'notifications'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 2,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 128,
'tries' => 1,
'timeout' => 90,
'nice' => 0,
],
],
'environments' => [
'production' => [
'supervisor-default' => [
'maxProcesses' => 10,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
'supervisor-messaging' => [
'maxProcesses' => 6,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
],
'local' => [
'supervisor-default' => [
'maxProcesses' => 3,
],
'supervisor-messaging' => [
'maxProcesses' => 2,
],
],
],
/*
|--------------------------------------------------------------------------
| File Watcher Configuration
|--------------------------------------------------------------------------
|
| The following list of directories and files will be watched when using
| the `horizon:listen` command. Whenever any directories or files are
| changed, Horizon will automatically restart to apply all changes.
|
*/
'watch' => [
'app',
'bootstrap',
'config/**/*.php',
'database/**/*.php',
'public/**/*.php',
'resources/**/*.php',
'routes',
'composer.lock',
'composer.json',
'.env',
],
];

View File

@@ -12,6 +12,20 @@ return [
'cache_store' => env('MESSAGING_TYPING_CACHE_STORE', 'redis'), 'cache_store' => env('MESSAGING_TYPING_CACHE_STORE', 'redis'),
], ],
'presence' => [
'ttl_seconds' => (int) env('MESSAGING_PRESENCE_TTL', 90),
'conversation_ttl_seconds' => (int) env('MESSAGING_CONVERSATION_PRESENCE_TTL', 45),
'cache_store' => env('MESSAGING_PRESENCE_CACHE_STORE', env('MESSAGING_TYPING_CACHE_STORE', 'redis')),
],
'recovery' => [
'max_messages' => (int) env('MESSAGING_RECOVERY_MAX_MESSAGES', 100),
],
'notifications' => [
'offline_fallback_only' => (bool) env('MESSAGING_OFFLINE_FALLBACK_ONLY', true),
],
'search' => [ 'search' => [
'index' => env('MESSAGING_MEILI_INDEX', 'messages'), 'index' => env('MESSAGING_MEILI_INDEX', 'messages'),
'page_size' => (int) env('MESSAGING_SEARCH_PAGE_SIZE', 20), 'page_size' => (int) env('MESSAGING_SEARCH_PAGE_SIZE', 20),

View File

@@ -44,6 +44,21 @@ return [
'connect_timeout_seconds'=> (int) env('VISION_GATEWAY_CONNECT_TIMEOUT', 3), 'connect_timeout_seconds'=> (int) env('VISION_GATEWAY_CONNECT_TIMEOUT', 3),
], ],
'vector_gateway' => [
'enabled' => env('VISION_VECTOR_GATEWAY_ENABLED', true),
'base_url' => env('VISION_VECTOR_GATEWAY_URL', ''),
'api_key' => env('VISION_VECTOR_GATEWAY_API_KEY', ''),
'collection' => env('VISION_VECTOR_GATEWAY_COLLECTION', 'images'),
'timeout_seconds' => (int) env('VISION_VECTOR_GATEWAY_TIMEOUT', 20),
'connect_timeout_seconds' => (int) env('VISION_VECTOR_GATEWAY_CONNECT_TIMEOUT', 5),
'retries' => (int) env('VISION_VECTOR_GATEWAY_RETRIES', 1),
'retry_delay_ms' => (int) env('VISION_VECTOR_GATEWAY_RETRY_DELAY_MS', 250),
'upsert_endpoint' => env('VISION_VECTOR_GATEWAY_UPSERT_ENDPOINT', '/vectors/upsert'),
'search_endpoint' => env('VISION_VECTOR_GATEWAY_SEARCH_ENDPOINT', '/vectors/search'),
'delete_endpoint' => env('VISION_VECTOR_GATEWAY_DELETE_ENDPOINT', '/vectors/delete'),
'collections_endpoint' => env('VISION_VECTOR_GATEWAY_COLLECTIONS_ENDPOINT', '/vectors/collections'),
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| LM Studio local multimodal inference (tag generation) | LM Studio local multimodal inference (tag generation)

View File

@@ -189,4 +189,4 @@ return new class extends Migration
} }
}); });
} }
}; };

View File

@@ -1,26 +1,44 @@
# Realtime Messaging # Realtime Messaging
Skinbase Nova messaging now uses Laravel Reverb, Laravel Broadcasting, Laravel Echo, and Redis-backed queues. Skinbase Nova messaging now uses Laravel Reverb, Laravel Broadcasting, Laravel Echo, Redis-backed queues, and Laravel Horizon for queue visibility.
## v2 capabilities
- Presence is exposed through a global `presence-messaging` channel for inbox-level online state.
- Conversation presence still uses the per-thread presence channel so the header can show who is actively viewing the room.
- Typing indicators remain ephemeral and Redis-backed.
- Read markers are stored on conversation participants and expanded into `message_reads` for durable receipts.
- The conversation list response now includes `summary.unread_total` for global badge consumers.
- Reconnect recovery uses `GET /api/messages/{conversation_id}/delta?after_message_id=...`.
- Presence heartbeats use `POST /api/messages/presence/heartbeat` and are intended only to support offline fallback notification logic plus server-side presence awareness.
## Local setup ## Local setup
1. Set the Reverb and Redis values in `.env`. 1. Set the Reverb, Redis, messaging, and Horizon values in `.env`.
2. Run `php artisan migrate`. 2. Run `php artisan migrate`.
3. Run `npm install` if dependencies are not installed. 3. Run `npm install` if dependencies are not installed.
4. Start the websocket server with `php artisan reverb:start --host=0.0.0.0 --port=8080`. 4. Start the websocket server with `php artisan reverb:start --host=0.0.0.0 --port=8080`.
5. Start queue workers with `php artisan queue:work redis --queue=broadcasts,default,notifications --tries=1`. 5. Start queue workers with `php artisan queue:work redis --queue=broadcasts,notifications,default --tries=1`.
6. Start the frontend with `npm run dev` or build assets with `npm run build`. 6. Start the frontend with `npm run dev` or build assets with `npm run build`.
## Horizon
- Horizon is installed for production queue monitoring and uses dedicated supervisors for `broadcasts` and `notifications` alongside the default queue.
- The scheduler now runs `php artisan horizon:snapshot` every five minutes so the dashboard records queue metrics.
- On Windows development machines, Horizon itself cannot run because PHP lacks `ext-pcntl` and `ext-posix`; that limitation does not affect Linux production deployments.
- Use `php artisan horizon` on Linux-based environments and keep the dashboard behind the `viewHorizon` gate.
## Production notes ## Production notes
- Use `BROADCAST_CONNECTION=reverb` and `QUEUE_CONNECTION=redis`. - Use `BROADCAST_CONNECTION=reverb` and `QUEUE_CONNECTION=redis`.
- Keep `MESSAGING_REALTIME=true` only when Reverb is configured and reachable from the browser. - Keep `MESSAGING_REALTIME=true` only when Reverb is configured and reachable from the browser.
- Terminate TLS in Nginx and proxy websocket traffic to the Reverb process. - Terminate TLS in Nginx and proxy websocket traffic to the Reverb process.
- Run both `php artisan reverb:start` and `php artisan queue:work redis --queue=broadcasts,default,notifications --tries=1` under Supervisor or systemd. - Run `php artisan reverb:start` and `php artisan horizon` under Supervisor or systemd.
- The chat UI falls back to HTTP polling only when realtime is disabled in config. - The chat UI falls back to HTTP polling only when realtime is disabled in config.
- Database notification fallback now only runs for recipients who are not marked online in messaging presence.
## Reconnect model ## Reconnect model
- The conversation view loads once via HTTP. - The conversation view loads once via HTTP.
- Live message, read, and typing updates arrive over websocket channels. - Live message, read, typing, and conversation summary updates arrive over websocket channels.
- When the socket reconnects, the client requests message deltas with `after_id` to merge missed messages idempotently. - When the socket reconnects, the client requests deltas from the explicit `delta` endpoint and merges them idempotently by message id, UUID, and client temp id.

File diff suppressed because one or more lines are too long

View File

@@ -61,10 +61,12 @@ function buildSearchPreview(item) {
function MessagesPage({ userId, username, activeConversationId: initialId }) { function MessagesPage({ userId, username, activeConversationId: initialId }) {
const [conversations, setConversations] = useState([]) const [conversations, setConversations] = useState([])
const [unreadTotal, setUnreadTotal] = useState(null)
const [loadingConvs, setLoadingConvs] = useState(true) const [loadingConvs, setLoadingConvs] = useState(true)
const [activeId, setActiveId] = useState(initialId ?? null) const [activeId, setActiveId] = useState(initialId ?? null)
const [realtimeEnabled, setRealtimeEnabled] = useState(false) const [realtimeEnabled, setRealtimeEnabled] = useState(false)
const [realtimeStatus, setRealtimeStatus] = useState('offline') const [realtimeStatus, setRealtimeStatus] = useState('offline')
const [onlineUserIds, setOnlineUserIds] = useState([])
const [typingByConversation, setTypingByConversation] = useState({}) const [typingByConversation, setTypingByConversation] = useState({})
const [showNewModal, setShowNewModal] = useState(false) const [showNewModal, setShowNewModal] = useState(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@@ -75,6 +77,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
try { try {
const data = await apiFetch('/api/messages/conversations') const data = await apiFetch('/api/messages/conversations')
setConversations(data.data ?? []) setConversations(data.data ?? [])
setUnreadTotal(Number.isFinite(Number(data?.summary?.unread_total)) ? Number(data.summary.unread_total) : null)
} catch (e) { } catch (e) {
console.error('Failed to load conversations', e) console.error('Failed to load conversations', e)
} finally { } finally {
@@ -173,6 +176,11 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
} }
setConversations((prev) => mergeConversationSummary(prev, nextConversation)) setConversations((prev) => mergeConversationSummary(prev, nextConversation))
const nextUnreadTotal = Number(payload?.summary?.unread_total)
if (Number.isFinite(nextUnreadTotal)) {
setUnreadTotal(nextUnreadTotal)
}
} }
channel.listen('.conversation.updated', handleConversationUpdated) channel.listen('.conversation.updated', handleConversationUpdated)
@@ -192,6 +200,79 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
} }
}, [realtimeEnabled, userId]) }, [realtimeEnabled, userId])
useEffect(() => {
if (!realtimeEnabled || !userId) {
setOnlineUserIds([])
return undefined
}
const echo = getEcho()
if (!echo) {
setOnlineUserIds([])
return undefined
}
const setMembers = (users) => {
const nextIds = (users ?? [])
.map((user) => Number(user?.id))
.filter((id) => Number.isFinite(id) && id !== Number(userId))
setOnlineUserIds(Array.from(new Set(nextIds)))
}
const channel = echo.join('messaging')
channel
.here(setMembers)
.joining((user) => setOnlineUserIds((prev) => (
prev.includes(Number(user?.id)) || Number(user?.id) === Number(userId)
? prev
: [...prev, Number(user.id)]
)))
.leaving((user) => setOnlineUserIds((prev) => prev.filter((id) => id !== Number(user?.id))))
return () => {
echo.leave('messaging')
}
}, [realtimeEnabled, userId])
useEffect(() => {
if (!userId) {
return undefined
}
let intervalId = null
const sendHeartbeat = () => {
if (document.visibilityState === 'hidden') {
return
}
apiFetch('/api/messages/presence/heartbeat', {
method: 'POST',
body: JSON.stringify(activeId ? { conversation_id: activeId } : {}),
}).catch(() => {})
}
const handleVisibilitySync = () => {
if (document.visibilityState === 'visible') {
sendHeartbeat()
}
}
sendHeartbeat()
intervalId = window.setInterval(sendHeartbeat, 25000)
window.addEventListener('focus', sendHeartbeat)
document.addEventListener('visibilitychange', handleVisibilitySync)
return () => {
if (intervalId) {
window.clearInterval(intervalId)
}
window.removeEventListener('focus', sendHeartbeat)
document.removeEventListener('visibilitychange', handleVisibilitySync)
}
}, [activeId, userId])
useEffect(() => { useEffect(() => {
if (!realtimeEnabled) { if (!realtimeEnabled) {
setTypingByConversation({}) setTypingByConversation({})
@@ -310,12 +391,16 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
history.replaceState(null, '', `/messages/${conv.id}`) history.replaceState(null, '', `/messages/${conv.id}`)
}, [loadConversations]) }, [loadConversations])
const handleMarkRead = useCallback((conversationId) => { const handleMarkRead = useCallback((conversationId, nextUnreadTotal = null) => {
setConversations((prev) => prev.map((conversation) => ( setConversations((prev) => prev.map((conversation) => (
conversation.id === conversationId conversation.id === conversationId
? { ...conversation, unread_count: 0 } ? { ...conversation, unread_count: 0 }
: conversation : conversation
))) )))
if (Number.isFinite(Number(nextUnreadTotal))) {
setUnreadTotal(Number(nextUnreadTotal))
}
}, []) }, [])
const handleConversationPatched = useCallback((patch) => { const handleConversationPatched = useCallback((patch) => {
@@ -369,7 +454,9 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
}, []) }, [])
const activeConversation = conversations.find((conversation) => conversation.id === activeId) ?? null const activeConversation = conversations.find((conversation) => conversation.id === activeId) ?? null
const unreadCount = conversations.reduce((sum, conversation) => sum + Number(conversation.unread_count || 0), 0) const unreadCount = Number.isFinite(Number(unreadTotal))
? Number(unreadTotal)
: conversations.reduce((sum, conversation) => sum + Number(conversation.unread_count || 0), 0)
const pinnedCount = conversations.reduce((sum, conversation) => { const pinnedCount = conversations.reduce((sum, conversation) => {
const me = conversation.my_participant ?? conversation.all_participants?.find((participant) => participant.user_id === userId) const me = conversation.my_participant ?? conversation.all_participants?.find((participant) => participant.user_id === userId)
return sum + (me?.is_pinned ? 1 : 0) return sum + (me?.is_pinned ? 1 : 0)
@@ -475,6 +562,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
loading={loadingConvs} loading={loadingConvs}
activeId={activeId} activeId={activeId}
currentUserId={userId} currentUserId={userId}
onlineUserIds={onlineUserIds}
typingByConversation={typingByConversation} typingByConversation={typingByConversation}
onSelect={handleSelectConversation} onSelect={handleSelectConversation}
/> />
@@ -490,6 +578,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
realtimeStatus={realtimeStatus} realtimeStatus={realtimeStatus}
currentUserId={userId} currentUserId={userId}
currentUsername={username} currentUsername={username}
onlineUserIds={onlineUserIds}
apiFetch={apiFetch} apiFetch={apiFetch}
onBack={() => { onBack={() => {
setActiveId(null) setActiveId(null)

View File

@@ -66,10 +66,9 @@ export default function ProfileGallery() {
</div> </div>
</div> </div>
<div className="mx-auto w-full max-w-6xl px-4 pt-6 md:px-6"> <div className="w-full pt-6">
<ProfileGalleryPanel <ProfileGalleryPanel
artworks={artworks} artworks={artworks}
featuredArtworks={featuredArtworks}
username={username} username={username}
/> />
</div> </div>

View File

@@ -1,7 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react' import React, { useState, useEffect, useCallback } from 'react'
import { usePage } from '@inertiajs/react' import { usePage } from '@inertiajs/react'
import ProfileHero from '../../components/profile/ProfileHero' import ProfileHero from '../../components/profile/ProfileHero'
import ProfileStatsRow from '../../components/profile/ProfileStatsRow'
import ProfileTabs from '../../components/profile/ProfileTabs' import ProfileTabs from '../../components/profile/ProfileTabs'
import TabArtworks from '../../components/profile/tabs/TabArtworks' import TabArtworks from '../../components/profile/tabs/TabArtworks'
import TabAchievements from '../../components/profile/tabs/TabAchievements' import TabAchievements from '../../components/profile/tabs/TabAchievements'
@@ -13,16 +12,26 @@ import TabActivity from '../../components/profile/tabs/TabActivity'
import TabPosts from '../../components/profile/tabs/TabPosts' import TabPosts from '../../components/profile/tabs/TabPosts'
import TabStories from '../../components/profile/tabs/TabStories' import TabStories from '../../components/profile/tabs/TabStories'
const VALID_TABS = ['artworks', 'stories', 'achievements', 'posts', 'collections', 'about', 'stats', 'favourites', 'activity'] const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'collections', 'about', 'stats', 'favourites', 'activity']
function getInitialTab() { function getInitialTab(initialTab = 'posts') {
try { if (typeof window === 'undefined') {
const sp = new URLSearchParams(window.location.search) return VALID_TABS.includes(initialTab) ? initialTab : 'posts'
const t = sp.get('tab')
return VALID_TABS.includes(t) ? t : 'artworks'
} catch {
return 'artworks'
} }
try {
const pathname = window.location.pathname.replace(/\/+$/, '')
const segments = pathname.split('/').filter(Boolean)
const lastSegment = segments.at(-1)
if (VALID_TABS.includes(lastSegment)) {
return lastSegment
}
} catch {
return VALID_TABS.includes(initialTab) ? initialTab : 'posts'
}
return VALID_TABS.includes(initialTab) ? initialTab : 'posts'
} }
/** /**
@@ -52,34 +61,37 @@ export default function ProfileShow() {
countryName, countryName,
isOwner, isOwner,
auth, auth,
initialTab,
profileUrl, profileUrl,
galleryUrl, galleryUrl,
profileTabUrls,
} = props } = props
const [activeTab, setActiveTab] = useState(getInitialTab) const [activeTab, setActiveTab] = useState(() => getInitialTab(initialTab))
const handleTabChange = useCallback((tab) => { const handleTabChange = useCallback((tab) => {
if (!VALID_TABS.includes(tab)) return if (!VALID_TABS.includes(tab)) return
setActiveTab(tab) setActiveTab(tab)
// Update URL query param without full navigation
try { try {
const url = new URL(window.location.href) const currentUrl = new URL(window.location.href)
if (tab === 'artworks') { const targetBase = profileTabUrls?.[tab] || `${profileUrl || `${window.location.origin}`}/${tab}`
url.searchParams.delete('tab') const nextUrl = new URL(targetBase, window.location.origin)
} else { const sharedPostId = currentUrl.searchParams.get('post')
url.searchParams.set('tab', tab)
} if (sharedPostId) {
window.history.pushState({}, '', url.toString()) nextUrl.searchParams.set('post', sharedPostId)
} catch (_) {} }
}, [])
window.history.pushState({}, '', nextUrl.toString())
} catch (_) {}
}, [profileTabUrls, profileUrl])
// Handle browser back/forward
useEffect(() => { useEffect(() => {
const onPop = () => setActiveTab(getInitialTab()) const onPop = () => setActiveTab(getInitialTab(initialTab))
window.addEventListener('popstate', onPop) window.addEventListener('popstate', onPop)
return () => window.removeEventListener('popstate', onPop) return () => window.removeEventListener('popstate', onPop)
}, []) }, [initialTab])
const isLoggedIn = !!(auth?.user) const isLoggedIn = !!(auth?.user)
@@ -98,9 +110,27 @@ export default function ProfileShow() {
? socialLinks.reduce((acc, l) => { acc[l.platform] = l; return acc }, {}) ? socialLinks.reduce((acc, l) => { acc[l.platform] = l; return acc }, {})
: (socialLinks ?? {}) : (socialLinks ?? {})
const contentShellClassName = activeTab === 'artworks'
? 'w-full px-4 md:px-6'
: activeTab === 'posts'
? 'mx-auto max-w-7xl px-4 md:px-6'
: 'max-w-6xl mx-auto px-4'
return ( return (
<div className="min-h-screen pb-16"> <div className="relative min-h-screen overflow-hidden pb-16">
{/* Hero section */} <div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-90"
style={{
background: 'radial-gradient(circle at top left, rgba(56,189,248,0.18), transparent 32%), radial-gradient(circle at 82% 10%, rgba(249,115,22,0.16), transparent 28%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #0a1220 100%)',
}}
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 opacity-[0.06]"
style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '180px' }}
/>
<ProfileHero <ProfileHero
user={user} user={user}
profile={profile} profile={profile}
@@ -121,26 +151,20 @@ export default function ProfileShow() {
) : null} ) : null}
/> />
{/* Stats pills row */} <div className="mt-6">
<ProfileStatsRow <ProfileTabs
stats={stats} activeTab={activeTab}
followerCount={followerCount} onTabChange={handleTabChange}
onTabChange={handleTabChange} />
/> </div>
{/* Sticky tabs */} <div className={`${contentShellClassName} pt-6`}>
<ProfileTabs
activeTab={activeTab}
onTabChange={handleTabChange}
/>
{/* Tab content area */}
<div className={activeTab === 'artworks' ? 'w-full px-4 md:px-6' : 'max-w-6xl mx-auto px-4'}>
{activeTab === 'artworks' && ( {activeTab === 'artworks' && (
<TabArtworks <TabArtworks
artworks={{ data: artworkList, next_cursor: artworkNextCursor }} artworks={{ data: artworkList, next_cursor: artworkNextCursor }}
featuredArtworks={featuredArtworks} featuredArtworks={featuredArtworks}
username={user.username || user.name} username={user.username || user.name}
galleryUrl={galleryUrl}
isActive isActive
/> />
)} )}
@@ -156,6 +180,7 @@ export default function ProfileShow() {
recentFollowers={recentFollowers} recentFollowers={recentFollowers}
socialLinks={socialLinksObj} socialLinks={socialLinksObj}
countryName={countryName} countryName={countryName}
profileUrl={profileUrl}
onTabChange={handleTabChange} onTabChange={handleTabChange}
/> />
)} )}
@@ -175,9 +200,16 @@ export default function ProfileShow() {
<TabAbout <TabAbout
user={user} user={user}
profile={profile} profile={profile}
stats={stats}
achievements={achievements}
artworks={artworkList}
creatorStories={creatorStories}
profileComments={profileComments}
socialLinks={socialLinksObj} socialLinks={socialLinksObj}
countryName={countryName} countryName={countryName}
followerCount={followerCount} followerCount={followerCount}
recentFollowers={recentFollowers}
leaderboardRank={leaderboardRank}
/> />
)} )}
{activeTab === 'stats' && ( {activeTab === 'stats' && (

View File

@@ -43,7 +43,7 @@ export default function PostActions({
} }
const handleCopyLink = () => { const handleCopyLink = () => {
const url = `${window.location.origin}/@${post.author.username}?tab=posts&post=${post.id}` const url = `${window.location.origin}/@${post.author.username}/posts?post=${post.id}`
navigator.clipboard?.writeText(url) navigator.clipboard?.writeText(url)
setShareMsg('Link copied!') setShareMsg('Link copied!')
setTimeout(() => setShareMsg(null), 2000) setTimeout(() => setShareMsg(null), 2000)

View File

@@ -1,67 +1,28 @@
import React, { useMemo, useState } from 'react' import React, { useState } from 'react'
const COLLAPSE_AT = 560 const COLLAPSE_AT = 560
function renderMarkdownSafe(text) {
const lines = text.split(/\n{2,}/)
return lines.map((line, lineIndex) => {
const parts = []
let rest = line
let key = 0
const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g
let match = linkPattern.exec(rest)
let lastIndex = 0
while (match) {
if (match.index > lastIndex) {
parts.push(<span key={`txt-${lineIndex}-${key++}`}>{rest.slice(lastIndex, match.index)}</span>)
}
parts.push(
<a
key={`lnk-${lineIndex}-${key++}`}
href={match[2]}
target="_blank"
rel="noopener noreferrer nofollow"
className="text-accent hover:underline"
>
{match[1]}
</a>,
)
lastIndex = match.index + match[0].length
match = linkPattern.exec(rest)
}
if (lastIndex < rest.length) {
parts.push(<span key={`txt-${lineIndex}-${key++}`}>{rest.slice(lastIndex)}</span>)
}
return (
<p key={`p-${lineIndex}`} className="text-sm leading-7 text-white/50">
{parts}
</p>
)
})
}
export default function ArtworkDescription({ artwork }) { export default function ArtworkDescription({ artwork }) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const content = (artwork?.description || '').trim() const content = (artwork?.description || '').trim()
const contentHtml = (artwork?.description_html || '').trim()
const collapsed = content.length > COLLAPSE_AT && !expanded const collapsed = content.length > COLLAPSE_AT && !expanded
const visibleText = collapsed ? `${content.slice(0, COLLAPSE_AT)}` : content
// useMemo must always be called (Rules of Hooks) — guard inside the callback
const rendered = useMemo(
() => (content.length > 0 ? renderMarkdownSafe(visibleText) : null),
[content, visibleText],
)
if (content.length === 0) return null if (content.length === 0) return null
return ( return (
<div> <div>
<div className="max-w-[720px] space-y-3 text-sm leading-7 text-white/50">{rendered}</div> <div
className={[
'max-w-[720px] overflow-hidden transition-[max-height] duration-300',
collapsed ? 'max-h-[11.5rem]' : 'max-h-[100rem]',
].join(' ')}
>
<div
className="prose prose-invert max-w-none text-sm leading-7 prose-p:my-3 prose-p:text-white/50 prose-a:text-accent prose-a:no-underline hover:prose-a:underline prose-strong:text-white/80 prose-em:text-white/70 prose-code:text-white/80"
dangerouslySetInnerHTML={{ __html: contentHtml }}
/>
</div>
{content.length > COLLAPSE_AT && ( {content.length > COLLAPSE_AT && (
<button <button

View File

@@ -97,12 +97,32 @@ function slugify(text) {
} }
function stripHtml(html) { function stripHtml(html) {
const decodeEntities = (value) => {
let decoded = String(value ?? '')
for (let index = 0; index < 4; index += 1) {
if (!decoded.includes('&')) break
if (typeof document !== 'undefined') {
const textarea = document.createElement('textarea')
textarea.innerHTML = decoded
const next = textarea.value
if (next === decoded) break
decoded = next
} else {
break
}
}
return decoded
}
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
const div = document.createElement('div') const div = document.createElement('div')
div.innerHTML = html div.innerHTML = decodeEntities(html)
return div.textContent || div.innerText || '' return div.textContent || div.innerText || ''
} }
return html.replace(/<[^>]*>/g, '') return decodeEntities(html).replace(/<[^>]*>/g, '')
} }
function formatDate(dateStr) { function formatDate(dateStr) {

View File

@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
export default function ConversationList({ conversations, loading, activeId, currentUserId, typingByConversation = {}, onSelect }) { export default function ConversationList({ conversations, loading, activeId, currentUserId, onlineUserIds = [], typingByConversation = {}, onSelect }) {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3"> <div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3">
@@ -28,6 +28,7 @@ export default function ConversationList({ conversations, loading, activeId, cur
conv={conversation} conv={conversation}
isActive={conversation.id === activeId} isActive={conversation.id === activeId}
currentUserId={currentUserId} currentUserId={currentUserId}
onlineUserIds={onlineUserIds}
typingUsers={typingByConversation[conversation.id] ?? []} typingUsers={typingByConversation[conversation.id] ?? []}
onClick={() => onSelect(conversation.id)} onClick={() => onSelect(conversation.id)}
/> />
@@ -37,7 +38,7 @@ export default function ConversationList({ conversations, loading, activeId, cur
) )
} }
function ConversationRow({ conv, isActive, currentUserId, typingUsers, onClick }) { function ConversationRow({ conv, isActive, currentUserId, onlineUserIds, typingUsers, onClick }) {
const label = convLabel(conv, currentUserId) const label = convLabel(conv, currentUserId)
const lastMsg = Array.isArray(conv.latest_message) ? conv.latest_message[0] : conv.latest_message const lastMsg = Array.isArray(conv.latest_message) ? conv.latest_message[0] : conv.latest_message
const preview = typingUsers.length > 0 const preview = typingUsers.length > 0
@@ -45,10 +46,17 @@ function ConversationRow({ conv, isActive, currentUserId, typingUsers, onClick }
: lastMsg?.body ? truncate(lastMsg.body, 88) : 'No messages yet' : lastMsg?.body ? truncate(lastMsg.body, 88) : 'No messages yet'
const unread = conv.unread_count ?? 0 const unread = conv.unread_count ?? 0
const myParticipant = conv.all_participants?.find((participant) => participant.user_id === currentUserId) const myParticipant = conv.all_participants?.find((participant) => participant.user_id === currentUserId)
const otherParticipant = conv.all_participants?.find((participant) => participant.user_id !== currentUserId)
const isArchived = myParticipant?.is_archived ?? false const isArchived = myParticipant?.is_archived ?? false
const isPinned = myParticipant?.is_pinned ?? false const isPinned = myParticipant?.is_pinned ?? false
const activeMembers = conv.all_participants?.filter((participant) => !participant.left_at).length ?? 0 const activeMembers = conv.all_participants?.filter((participant) => !participant.left_at).length ?? 0
const typeLabel = conv.type === 'group' ? `${activeMembers} members` : 'Direct message' const onlineMembers = conv.type === 'group'
? conv.all_participants?.filter((participant) => participant.user_id !== currentUserId && onlineUserIds.includes(Number(participant.user_id)) && !participant.left_at).length ?? 0
: 0
const isDirectOnline = conv.type === 'direct' && otherParticipant ? onlineUserIds.includes(Number(otherParticipant.user_id)) : false
const typeLabel = conv.type === 'group'
? (onlineMembers > 0 ? `${onlineMembers} online` : `${activeMembers} members`)
: (isDirectOnline ? 'Online now' : 'Direct message')
const senderLabel = lastMsg?.sender?.username ? `@${lastMsg.sender.username}` : null const senderLabel = lastMsg?.sender?.username ? `@${lastMsg.sender.username}` : null
const initials = label const initials = label
.split(/\s+/) .split(/\s+/)
@@ -64,8 +72,11 @@ function ConversationRow({ conv, isActive, currentUserId, typingUsers, onClick }
className={`w-full rounded-[24px] border px-4 py-4 text-left transition ${isActive ? 'border-sky-400/28 bg-sky-500/[0.12] shadow-[0_0_0_1px_rgba(56,189,248,0.08)]' : 'border-white/[0.06] bg-white/[0.03] hover:border-white/[0.1] hover:bg-white/[0.05]'} ${isArchived ? 'opacity-65' : ''}`} className={`w-full rounded-[24px] border px-4 py-4 text-left transition ${isActive ? 'border-sky-400/28 bg-sky-500/[0.12] shadow-[0_0_0_1px_rgba(56,189,248,0.08)]' : 'border-white/[0.06] bg-white/[0.03] hover:border-white/[0.1] hover:bg-white/[0.05]'} ${isArchived ? 'opacity-65' : ''}`}
> >
<div className="flex gap-3"> <div className="flex gap-3">
<div className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[0_10px_25px_rgba(0,0,0,0.18)] ${conv.type === 'group' ? 'bg-gradient-to-br from-fuchsia-500 to-violet-600' : 'bg-gradient-to-br from-sky-500 to-cyan-500'}`}> <div className="relative shrink-0">
{initials} <div className={`flex h-12 w-12 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[0_10px_25px_rgba(0,0,0,0.18)] ${conv.type === 'group' ? 'bg-gradient-to-br from-fuchsia-500 to-violet-600' : 'bg-gradient-to-br from-sky-500 to-cyan-500'}`}>
{initials}
</div>
{isDirectOnline ? <span className="absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full border-2 border-[#0a101a] bg-emerald-300 shadow-[0_0_0_6px_rgba(16,185,129,0.08)]" /> : null}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">

View File

@@ -9,6 +9,7 @@ export default function ConversationThread({
realtimeStatus, realtimeStatus,
currentUserId, currentUserId,
currentUsername, currentUsername,
onlineUserIds,
apiFetch, apiFetch,
onBack, onBack,
onMarkRead, onMarkRead,
@@ -65,6 +66,13 @@ export default function ConversationThread({
.map((participant) => participant.user?.username) .map((participant) => participant.user?.username)
.filter(Boolean) .filter(Boolean)
), [currentUserId, participants]) ), [currentUserId, participants])
const directParticipant = useMemo(() => (
participants.find((participant) => participant.user_id !== currentUserId) ?? null
), [currentUserId, participants])
const remoteIsOnline = directParticipant ? onlineUserIds.includes(Number(directParticipant.user_id)) : false
const remoteIsViewingConversation = directParticipant
? presenceUsers.some((user) => Number(user?.id) === Number(directParticipant.user_id))
: false
const filteredMessages = useMemo(() => { const filteredMessages = useMemo(() => {
const query = threadSearch.trim().toLowerCase() const query = threadSearch.trim().toLowerCase()
@@ -185,7 +193,7 @@ export default function ConversationThread({
} }
: participant : participant
))) )))
onMarkRead?.(conversationId) onMarkRead?.(conversationId, response?.unread_total ?? null)
} catch { } catch {
// no-op // no-op
} }
@@ -309,7 +317,7 @@ export default function ConversationThread({
} }
try { try {
const data = await apiFetch(`/api/messages/${conversationId}?after_id=${encodeURIComponent(lastServerMessage.id)}`) const data = await apiFetch(`/api/messages/${conversationId}/delta?after_message_id=${encodeURIComponent(lastServerMessage.id)}`)
const incoming = normalizeMessages(data.data ?? [], currentUserId) const incoming = normalizeMessages(data.data ?? [], currentUserId)
if (incoming.length > 0) { if (incoming.length > 0) {
setMessages((prev) => mergeMessageLists(prev, incoming)) setMessages((prev) => mergeMessageLists(prev, incoming))
@@ -622,9 +630,11 @@ export default function ConversationThread({
}, [apiFetch, conversation?.title, conversationId, draftTitle, patchConversation]) }, [apiFetch, conversation?.title, conversationId, draftTitle, patchConversation])
const visibleMessages = filteredMessages const visibleMessages = filteredMessages
const messagesWithDecorators = useMemo(() => decorateMessages(visibleMessages, currentUserId, myParticipant?.last_read_at ?? null), [visibleMessages, currentUserId, myParticipant?.last_read_at]) const messagesWithDecorators = useMemo(() => decorateMessages(visibleMessages, currentUserId, myParticipant), [visibleMessages, currentUserId, myParticipant])
const typingLabel = buildTypingLabel(typingUsers) const typingLabel = buildTypingLabel(typingUsers)
const presenceLabel = presenceUsers.length > 0 ? `${presenceUsers.length} active now` : null const presenceLabel = conversation?.type === 'group'
? (presenceUsers.length > 0 ? `${presenceUsers.length} active now` : null)
: (remoteIsViewingConversation ? 'Viewing this conversation' : (remoteIsOnline ? 'Online now' : null))
const typingSummary = typingUsers.length > 0 const typingSummary = typingUsers.length > 0
? `${typingLabel} ${conversation?.type === 'group' ? '' : 'Reply will appear here instantly.'}`.trim() ? `${typingLabel} ${conversation?.type === 'group' ? '' : 'Reply will appear here instantly.'}`.trim()
: null : null
@@ -796,7 +806,7 @@ export default function ConversationThread({
const showAvatar = !previous || previous.sender_id !== message.sender_id const showAvatar = !previous || previous.sender_id !== message.sender_id
const endsSequence = !next || next.sender_id !== message.sender_id const endsSequence = !next || next.sender_id !== message.sender_id
const seenText = isLastMineMessage(visibleMessages, index, currentUserId) const seenText = isLastMineMessage(visibleMessages, index, currentUserId)
? buildSeenText(participants, currentUserId) ? buildSeenText(participants, currentUserId, message)
: null : null
return ( return (
@@ -1037,29 +1047,38 @@ function isLastMineMessage(messages, index, currentUserId) {
return true return true
} }
function buildSeenText(participants, currentUserId) { function buildSeenText(participants, currentUserId, message) {
const seenBy = participants const seenBy = participants.filter((participant) => participant.user_id !== currentUserId && participantHasReadMessage(participant, message))
.filter((participant) => participant.user_id !== currentUserId && participant.last_read_at)
.map((participant) => participant.user?.username)
.filter(Boolean)
if (seenBy.length === 0) return 'Sent' if (seenBy.length === 0) return 'Sent'
if (seenBy.length === 1) return `Seen by @${seenBy[0]}`
if (seenBy.length === 1) {
const readAt = seenBy[0]?.last_read_at
return readAt ? `Seen ${formatSeenTime(readAt)}` : 'Seen'
}
return `Seen by ${seenBy.length} people` return `Seen by ${seenBy.length} people`
} }
function decorateMessages(messages, currentUserId, lastReadAt) { function decorateMessages(messages, currentUserId, participant) {
let unreadMarked = false let unreadMarked = false
const lastReadMessageId = Number(participant?.last_read_message_id ?? 0)
const lastReadAt = participant?.last_read_at ?? null
return messages.map((message, index) => { return messages.map((message, index) => {
const previous = messages[index - 1] const previous = messages[index - 1]
const currentDay = dayKey(message.created_at) const currentDay = dayKey(message.created_at)
const previousDay = previous ? dayKey(previous.created_at) : null const previousDay = previous ? dayKey(previous.created_at) : null
const shouldMarkUnread = !unreadMarked const shouldMarkUnread = !unreadMarked
&& !!lastReadAt
&& message.sender_id !== currentUserId && message.sender_id !== currentUserId
&& !message.deleted_at && !message.deleted_at
&& new Date(message.created_at).getTime() > new Date(lastReadAt).getTime() && (
lastReadMessageId > 0
? Number(message.id) > lastReadMessageId
: lastReadAt
? new Date(message.created_at).getTime() > new Date(lastReadAt).getTime()
: true
)
if (shouldMarkUnread) unreadMarked = true if (shouldMarkUnread) unreadMarked = true
@@ -1071,6 +1090,26 @@ function decorateMessages(messages, currentUserId, lastReadAt) {
}) })
} }
function participantHasReadMessage(participant, message) {
const lastReadMessageId = Number(participant?.last_read_message_id ?? 0)
if (lastReadMessageId > 0) {
return Number(message?.id ?? 0) > 0 && lastReadMessageId >= Number(message.id)
}
if (participant?.last_read_at && message?.created_at) {
return new Date(participant.last_read_at).getTime() >= new Date(message.created_at).getTime()
}
return false
}
function formatSeenTime(iso) {
return new Date(iso).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
}
function dayKey(iso) { function dayKey(iso) {
const date = new Date(iso) const date = new Date(iso)
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`

View File

@@ -9,46 +9,31 @@ const SORT_OPTIONS = [
{ value: 'favs', label: 'Most Favourited' }, { value: 'favs', label: 'Most Favourited' },
] ]
function slugify(str) { function GalleryToolbar({ sort, onSort }) {
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
function FeaturedStrip({ featuredArtworks }) {
if (!featuredArtworks?.length) return null
return ( return (
<div className="mb-7 rounded-2xl border border-white/10 bg-white/[0.02] p-4 md:p-5"> <div className="mb-5 flex flex-wrap items-center gap-3">
<h2 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-slate-400"> <span className="text-xs font-semibold uppercase tracking-wider text-slate-500">Sort</span>
<i className="fa-solid fa-star fa-fw text-amber-400" /> <div className="flex flex-wrap gap-1 rounded-2xl border border-white/10 bg-white/[0.03] p-1">
Featured {SORT_OPTIONS.map((opt) => (
</h2> <button
<div className="scrollbar-hide flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2"> key={opt.value}
{featuredArtworks.slice(0, 5).map((art) => ( type="button"
<a onClick={() => onSort(opt.value)}
key={art.id} className={`rounded-xl px-3.5 py-2 text-xs font-medium transition-all ${
href={`/art/${art.id}/${slugify(art.name)}`} sort === opt.value
className="group w-56 shrink-0 snap-start md:w-64" ? 'bg-sky-500/20 text-sky-300 ring-1 ring-sky-400/40'
: 'text-slate-400 hover:bg-white/5 hover:text-white'
}`}
> >
<div className="aspect-[5/3] overflow-hidden rounded-xl bg-black/30 ring-1 ring-white/10 transition-all hover:ring-sky-400/40"> {opt.label}
<img </button>
src={art.thumb}
alt={art.name}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy"
/>
</div>
<p className="mt-2 truncate text-sm text-slate-300 transition-colors group-hover:text-white">
{art.name}
</p>
{art.label ? <p className="truncate text-[11px] text-slate-600">{art.label}</p> : null}
</a>
))} ))}
</div> </div>
</div> </div>
) )
} }
export default function ProfileGalleryPanel({ artworks, featuredArtworks, username }) { export default function ProfileGalleryPanel({ artworks, username }) {
const [sort, setSort] = useState('latest') const [sort, setSort] = useState('latest')
const [items, setItems] = useState(artworks?.data ?? artworks ?? []) const [items, setItems] = useState(artworks?.data ?? artworks ?? [])
const [nextCursor, setNextCursor] = useState(artworks?.next_cursor ?? null) const [nextCursor, setNextCursor] = useState(artworks?.next_cursor ?? null)
@@ -74,36 +59,20 @@ export default function ProfileGalleryPanel({ artworks, featuredArtworks, userna
return ( return (
<> <>
<FeaturedStrip featuredArtworks={featuredArtworks} /> <div className="mx-auto w-full max-w-6xl px-4 md:px-6">
<GalleryToolbar sort={sort} onSort={handleSort} />
<div className="mb-5 flex flex-wrap items-center gap-3">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">Sort</span>
<div className="flex flex-wrap gap-1">
{SORT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleSort(opt.value)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
sort === opt.value
? 'bg-sky-500/20 text-sky-300 ring-1 ring-sky-400/40'
: 'text-slate-400 hover:bg-white/5 hover:text-white'
}`}
>
{opt.label}
</button>
))}
</div>
</div> </div>
<MasonryGallery <div className="w-full px-4 md:px-6 xl:px-8">
key={`profile-${username}-${sort}`} <MasonryGallery
artworks={items} key={`profile-${username}-${sort}`}
galleryType="profile" artworks={items}
cursorEndpoint={`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`} galleryType="profile"
initialNextCursor={nextCursor} cursorEndpoint={`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`}
limit={24} initialNextCursor={nextCursor}
/> limit={24}
/>
</div>
</> </>
) )
} }

View File

@@ -4,6 +4,11 @@ import LevelBadge from '../xp/LevelBadge'
import XPProgressBar from '../xp/XPProgressBar' import XPProgressBar from '../xp/XPProgressBar'
import FollowButton from '../social/FollowButton' import FollowButton from '../social/FollowButton'
function formatCompactNumber(value) {
const numeric = Number(value ?? 0)
return numeric.toLocaleString()
}
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, heroBgUrl, countryName, leaderboardRank, extraActions = null }) { export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
const [following, setFollowing] = useState(viewerIsFollowing) const [following, setFollowing] = useState(viewerIsFollowing)
const [count, setCount] = useState(followerCount) const [count, setCount] = useState(followerCount)
@@ -17,26 +22,53 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) ? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
: null : null
const bio = profile?.bio || profile?.about || '' const bio = profile?.bio || profile?.about || ''
const heroFacts = [
{ label: 'Followers', value: formatCompactNumber(count) },
{ label: 'Level', value: `Lv ${formatCompactNumber(user?.level ?? 1)}` },
{ label: 'Progress', value: `${Math.round(Number(user?.progress_percent ?? 0))}%` },
{ label: 'Member since', value: joinDate ?? 'Recently joined' },
]
return ( return (
<> <>
<div className="max-w-6xl mx-auto px-4 pt-4"> <div className="relative mx-auto max-w-7xl px-4 pt-4 md:pt-6">
<div className="relative overflow-hidden rounded-2xl border border-white/10"> <div
aria-hidden="true"
className="pointer-events-none absolute inset-x-10 top-8 -z-10 h-44 rounded-full blur-3xl"
style={{
background: 'linear-gradient(90deg, rgba(56,189,248,0.18), rgba(249,115,22,0.14), rgba(59,130,246,0.12))',
}}
/>
<div className="relative overflow-hidden rounded-[32px] border border-white/10 bg-[#09111f]/80 shadow-[0_24px_80px_rgba(2,6,23,0.55)]">
<div <div
className="w-full h-[180px] md:h-[220px] xl:h-[252px]" className="w-full h-[208px] md:h-[248px] xl:h-[288px]"
style={{ style={{
background: coverUrl background: coverUrl
? `url('${coverUrl}') center ${coverPosition}% / cover no-repeat` ? `url('${coverUrl}') center ${coverPosition}% / cover no-repeat`
: 'linear-gradient(140deg, #0f1724 0%, #101a2a 45%, #0a1220 100%)', : 'linear-gradient(140deg, #07101d 0%, #0b1726 42%, #07111e 100%)',
position: 'relative', position: 'relative',
}} }}
> >
<div className="absolute left-4 top-4 z-20 flex flex-wrap items-center gap-2 md:left-6 md:top-6">
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-black/30 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-200 backdrop-blur-md">
<span className="h-2 w-2 rounded-full bg-sky-400 shadow-[0_0_12px_rgba(56,189,248,0.9)]" />
Creator profile
</span>
{leaderboardRank?.rank ? (
<span className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100 backdrop-blur-md">
<i className="fa-solid fa-sparkles text-[10px]" />
Top #{leaderboardRank.rank} this week
</span>
) : null}
</div>
{isOwner ? ( {isOwner ? (
<div className="absolute right-3 top-3 z-20"> <div className="absolute right-4 top-4 z-20 md:right-6 md:top-6">
<button <button
type="button" type="button"
onClick={() => setEditorOpen(true)} onClick={() => setEditorOpen(true)}
className="inline-flex items-center gap-2 rounded-lg border border-white/20 bg-black/40 px-3 py-2 text-xs font-medium text-white hover:bg-black/60" className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-black/35 px-3.5 py-2 text-xs font-medium text-white backdrop-blur-md transition-colors hover:bg-black/55"
aria-label="Edit cover image" aria-label="Edit cover image"
> >
<i className="fa-solid fa-image" /> <i className="fa-solid fa-image" />
@@ -49,148 +81,165 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
background: coverUrl background: coverUrl
? 'linear-gradient(to bottom, rgba(0,0,0,0.2), rgba(0,0,0,0.62))' ? 'linear-gradient(180deg, rgba(2,6,23,0.16) 0%, rgba(2,6,23,0.28) 38%, rgba(2,6,23,0.9) 100%)'
: 'radial-gradient(ellipse at 16% 40%, rgba(77,163,255,.18) 0%, transparent 60%), radial-gradient(ellipse at 84% 22%, rgba(224,122,33,.12) 0%, transparent 54%)', : 'radial-gradient(ellipse at 16% 40%, rgba(77,163,255,.18) 0%, transparent 60%), radial-gradient(ellipse at 84% 22%, rgba(224,122,33,.14) 0%, transparent 54%)',
}} }}
/> />
<div className="absolute inset-0 opacity-[0.06] pointer-events-none" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '32px' }} /> <div className="absolute inset-0 opacity-[0.06] pointer-events-none" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '32px' }} />
</div> </div>
</div>
<div className="relative -mt-14 md:-mt-16 pb-4 px-1"> <div className="relative px-4 pb-6 md:px-7 md:pb-7">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:gap-5"> <div className="relative -mt-16 flex flex-col gap-5 md:-mt-20 md:flex-row md:items-start md:gap-6">
<div className="mx-auto z-10 shrink-0 md:mx-0"> <div className="mx-auto z-10 shrink-0 md:mx-0">
<img <img
src={user.avatar_url || '/default/avatar_default.webp'} src={user.avatar_url || '/default/avatar_default.webp'}
alt={`${uname}'s avatar`} alt={`${uname}'s avatar`}
className="h-[104px] w-[104px] rounded-full border-2 border-white/15 object-cover shadow-[0_0_0_6px_rgba(15,23,36,0.95),0_10px_32px_rgba(0,0,0,0.6)] md:h-[116px] md:w-[116px]" className="h-[112px] w-[112px] rounded-[28px] border border-white/15 bg-[#0b1320] object-cover shadow-[0_0_0_8px_rgba(9,17,31,0.92),0_22px_44px_rgba(2,6,23,0.5)] md:h-[132px] md:w-[132px]"
/> />
</div>
<div className="min-w-0 flex-1 text-center md:text-left">
<h1 className="text-[28px] font-bold leading-tight tracking-tight text-white md:text-[34px]">
{displayName}
</h1>
<p className="mt-0.5 font-mono text-sm text-slate-400">@{uname}</p>
<div className="mt-3 flex flex-wrap items-center justify-center gap-2 md:justify-start">
<LevelBadge level={user?.level} rank={user?.rank} />
{leaderboardRank?.rank ? (
<span className="inline-flex items-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-sky-100">
Rank #{leaderboardRank.rank} this week
</span>
) : null}
</div> </div>
<div className="mt-2 flex flex-wrap items-center justify-center gap-2.5 text-xs text-slate-400 md:justify-start"> <div className="min-w-0 flex-1 text-center md:text-left">
{countryName ? ( <div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_280px] xl:items-start">
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1"> <div className="min-w-0">
{profile?.country_code ? ( <div className="flex flex-wrap items-center justify-center gap-2 md:justify-start">
<img <span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`} <i className="fa-solid fa-stars text-[10px] text-sky-300" />
alt={countryName} Profile spotlight
className="w-4 h-auto rounded-sm" </span>
onError={(event) => { event.target.style.display = 'none' }} </div>
/>
<h1 className="mt-3 text-[30px] font-semibold leading-tight tracking-[-0.03em] text-white md:text-[42px]">
{displayName}
</h1>
<p className="mt-1 font-mono text-sm text-slate-400 md:text-[15px]">@{uname}</p>
<div className="mt-4 flex flex-wrap items-center justify-center gap-2 md:justify-start">
<LevelBadge level={user?.level} rank={user?.rank} />
{countryName ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300">
{profile?.country_code ? (
<img
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
alt={countryName}
className="h-auto w-4 rounded-sm"
onError={(event) => { event.target.style.display = 'none' }}
/>
) : null}
{countryName}
</span>
) : null}
{joinDate ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300">
<i className="fa-solid fa-calendar-days fa-fw text-slate-500" />
Joined {joinDate}
</span>
) : null}
{profile?.website ? (
<a
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
target="_blank"
rel="nofollow noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1.5 text-xs text-sky-200 transition-colors hover:border-sky-300/35 hover:bg-sky-400/15"
>
<i className="fa-solid fa-link fa-fw" />
{(() => {
try {
const url = profile.website.startsWith('http') ? profile.website : `https://${profile.website}`
return new URL(url).hostname
} catch {
return profile.website
}
})()}
</a>
) : null}
</div>
{bio ? (
<p className="mx-auto mt-4 max-w-2xl text-sm leading-relaxed text-slate-300/90 md:mx-0 md:text-[15px]">
{bio}
</p>
) : null} ) : null}
{countryName}
</span>
) : null}
{joinDate ? ( <XPProgressBar
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1"> xp={user?.xp}
<i className="fa-solid fa-calendar-days fa-fw opacity-70" /> currentLevelXp={user?.current_level_xp}
Joined {joinDate} nextLevelXp={user?.next_level_xp}
</span> progressPercent={user?.progress_percent}
) : null} maxLevel={user?.max_level}
className="mt-4 max-w-3xl"
/>
</div>
<div className="space-y-3 xl:pt-1">
<div className="flex flex-wrap items-center justify-center gap-2 xl:justify-end">
{extraActions}
{isOwner ? (
<>
<a
href="/dashboard/profile"
className="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/[0.04] px-4 py-2.5 text-sm font-medium text-slate-200 transition-all hover:bg-white/[0.08] hover:text-white"
aria-label="Edit profile"
>
<i className="fa-solid fa-pen fa-fw" />
Edit Profile
</a>
<a
href="/studio"
className="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-sky-500 to-cyan-400 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-[0_18px_36px_rgba(14,165,233,0.28)] transition-transform hover:-translate-y-0.5"
aria-label="Open Studio"
>
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
Studio
</a>
</>
) : (
<>
<FollowButton
username={uname}
initialFollowing={following}
initialCount={count}
followingClassName="border border-emerald-400/40 bg-emerald-500/12 text-emerald-300 hover:bg-emerald-500/18"
idleClassName="border border-sky-400/40 bg-sky-500/12 text-sky-200 hover:bg-sky-500/20"
onChange={({ following: nextFollowing, followersCount }) => {
setFollowing(nextFollowing)
setCount(followersCount)
}}
/>
<button
type="button"
onClick={() => {
if (navigator.share) {
navigator.share({ title: `${displayName} on Skinbase`, url: window.location.href })
} else {
navigator.clipboard.writeText(window.location.href)
}
}}
aria-label="Share profile"
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/[0.08] hover:text-white"
>
<i className="fa-solid fa-share-nodes fa-fw" />
Share
</button>
</>
)}
</div>
<div className="grid grid-cols-2 gap-2 text-left">
{heroFacts.map((fact) => (
<div
key={fact.label}
className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
>
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">{fact.label}</div>
<div className="mt-1.5 text-sm font-semibold tracking-tight text-white md:text-base">{fact.value}</div>
</div>
))}
</div>
</div>
</div>
{profile?.website ? (
<a
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
target="_blank"
rel="nofollow noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-sky-300 transition-colors hover:bg-white/10 hover:text-sky-200"
>
<i className="fa-solid fa-link fa-fw" />
{(() => {
try {
const url = profile.website.startsWith('http') ? profile.website : `https://${profile.website}`
return new URL(url).hostname
} catch {
return profile.website
}
})()}
</a>
) : null}
</div> </div>
{bio ? (
<p className="mx-auto mt-3 max-w-2xl line-clamp-2 text-sm leading-relaxed text-slate-300/90 md:mx-0 md:line-clamp-3">
{bio}
</p>
) : null}
<XPProgressBar
xp={user?.xp}
currentLevelXp={user?.current_level_xp}
nextLevelXp={user?.next_level_xp}
progressPercent={user?.progress_percent}
maxLevel={user?.max_level}
className="mt-4 max-w-xl"
/>
</div>
<div className="shrink-0 flex items-center justify-center gap-2 pb-0.5 md:justify-end">
{extraActions}
{isOwner ? (
<>
<a
href="/dashboard/profile"
className="inline-flex items-center gap-2 rounded-xl border border-white/15 px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/5 hover:text-white"
aria-label="Edit profile"
>
<i className="fa-solid fa-pen fa-fw" />
Edit Profile
</a>
<a
href="/studio"
className="inline-flex items-center gap-2 rounded-xl bg-sky-600 px-4 py-2.5 text-sm font-medium text-white shadow-lg shadow-sky-900/30 transition-all hover:bg-sky-500"
aria-label="Open Studio"
>
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
Studio
</a>
</>
) : (
<>
<FollowButton
username={uname}
initialFollowing={following}
initialCount={count}
followingClassName="bg-green-500/10 border border-green-400/40 text-green-400 hover:bg-green-500/15"
idleClassName="bg-sky-500/10 border border-sky-400/40 text-sky-400 hover:bg-sky-500/20"
onChange={({ following: nextFollowing, followersCount }) => {
setFollowing(nextFollowing)
setCount(followersCount)
}}
/>
<button
type="button"
onClick={() => {
if (navigator.share) {
navigator.share({ title: `${displayName} on Skinbase`, url: window.location.href })
} else {
navigator.clipboard.writeText(window.location.href)
}
}}
aria-label="Share profile"
className="rounded-xl border border-white/10 p-2.5 text-slate-400 transition-all hover:bg-white/5 hover:text-white"
>
<i className="fa-solid fa-share-nodes fa-fw" />
</button>
</>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,57 +0,0 @@
import React from 'react'
const PILLS = [
{ key: 'uploads_count', label: 'Artworks', icon: 'fa-images', tab: 'artworks' },
{ key: 'downloads_received_count', label: 'Downloads', icon: 'fa-download', tab: null },
{ key: 'follower_count', label: 'Followers', icon: 'fa-users', tab: 'about' },
{ key: 'following_count', label: 'Following', icon: 'fa-user-check', tab: 'about' },
{ key: 'artwork_views_received_count', label: 'Views', icon: 'fa-eye', tab: 'stats' },
{ key: 'awards_received_count', label: 'Awards', icon: 'fa-trophy', tab: 'stats' },
]
/**
* ProfileStatsRow
* Horizontal scrollable pill row of stat counts.
* Clicking a pill navigates to the relevant tab.
*/
export default function ProfileStatsRow({ stats, followerCount, onTabChange }) {
const values = {
uploads_count: stats?.uploads_count ?? 0,
downloads_received_count: stats?.downloads_received_count ?? 0,
follower_count: followerCount ?? 0,
following_count: stats?.following_count ?? 0,
artwork_views_received_count: stats?.artwork_views_received_count ?? 0,
awards_received_count: stats?.awards_received_count ?? 0,
}
return (
<div className="border-b border-white/10" style={{ background: 'rgba(255,255,255,0.02)' }}>
<div className="max-w-6xl mx-auto px-4">
<div className="grid grid-cols-3 md:grid-cols-6 gap-2 py-3">
{PILLS.map((pill) => (
<button
key={pill.key}
onClick={() => pill.tab && onTabChange(pill.tab)}
title={pill.label}
disabled={!pill.tab}
className={`
flex flex-col items-center justify-center gap-1 px-2 py-3 rounded-xl text-sm transition-all text-center
border border-white/10 bg-white/[0.02]
${pill.tab
? 'cursor-pointer hover:bg-white/[0.06] hover:border-white/20 hover:text-white text-slate-300 group'
: 'cursor-default text-slate-400 opacity-90'
}
`}
>
<i className={`fa-solid ${pill.icon} fa-fw text-xs ${pill.tab ? 'opacity-70 group-hover:opacity-100' : 'opacity-60'}`} />
<span className="font-bold text-white tabular-nums text-base leading-none">
{Number(values[pill.key]).toLocaleString()}
</span>
<span className="text-slate-500 text-[11px] uppercase tracking-wide leading-none">{pill.label}</span>
</button>
))}
</div>
</div>
</div>
)
}

View File

@@ -1,10 +1,10 @@
import React, { useEffect, useRef } from 'react' import React, { useEffect, useRef } from 'react'
export const TABS = [ export const TABS = [
{ id: 'posts', label: 'Posts', icon: 'fa-newspaper' },
{ id: 'artworks', label: 'Artworks', icon: 'fa-images' }, { id: 'artworks', label: 'Artworks', icon: 'fa-images' },
{ id: 'stories', label: 'Stories', icon: 'fa-feather-pointed' }, { id: 'stories', label: 'Stories', icon: 'fa-feather-pointed' },
{ id: 'achievements', label: 'Achievements', icon: 'fa-trophy' }, { id: 'achievements', label: 'Achievements', icon: 'fa-trophy' },
{ id: 'posts', label: 'Posts', icon: 'fa-newspaper' },
{ id: 'collections', label: 'Collections', icon: 'fa-layer-group' }, { id: 'collections', label: 'Collections', icon: 'fa-layer-group' },
{ id: 'about', label: 'About', icon: 'fa-id-card' }, { id: 'about', label: 'About', icon: 'fa-id-card' },
{ id: 'stats', label: 'Stats', icon: 'fa-chart-bar' }, { id: 'stats', label: 'Stats', icon: 'fa-chart-bar' },
@@ -23,7 +23,6 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
const navRef = useRef(null) const navRef = useRef(null)
const activeRef = useRef(null) const activeRef = useRef(null)
// Scroll active tab into view on mount/change
useEffect(() => { useEffect(() => {
if (activeRef.current && navRef.current) { if (activeRef.current && navRef.current) {
activeRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }) activeRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
@@ -31,13 +30,14 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
}, [activeTab]) }, [activeTab])
return ( return (
<nav <div className="sticky top-0 z-30 border-b border-white/10 bg-[#08111f]/80 backdrop-blur-2xl">
ref={navRef} <nav
className="profile-tabs-sticky sticky z-30 bg-[#0c1525]/95 backdrop-blur-xl border-b border-white/10 overflow-x-auto scrollbar-hide" ref={navRef}
aria-label="Profile sections" className="profile-tabs-sticky overflow-x-auto scrollbar-hide"
role="tablist" aria-label="Profile sections"
> role="tablist"
<div className="max-w-6xl mx-auto px-3 flex gap-1 py-1 min-w-max sm:min-w-0"> >
<div className="mx-auto flex w-max min-w-full gap-2 px-3 py-3 justify-center xl:items-stretch">
{TABS.map((tab) => { {TABS.map((tab) => {
const isActive = activeTab === tab.id const isActive = activeTab === tab.id
return ( return (
@@ -49,28 +49,29 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
aria-selected={isActive} aria-selected={isActive}
aria-controls={`tabpanel-${tab.id}`} aria-controls={`tabpanel-${tab.id}`}
className={` className={`
relative flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap rounded-lg group relative flex items-center gap-2.5 rounded-2xl border px-3.5 py-3 text-sm font-medium whitespace-nowrap
transition-colors duration-150 outline-none outline-none transition-all duration-150 focus-visible:ring-2 focus-visible:ring-sky-400/70
focus-visible:ring-2 focus-visible:ring-sky-400/70 rounded-t
${isActive ${isActive
? 'text-white bg-white/[0.05]' ? 'border-sky-300/25 bg-gradient-to-br from-sky-400/18 via-white/[0.06] to-cyan-400/10 text-white shadow-[0_16px_32px_rgba(14,165,233,0.12)]'
: 'text-slate-400 hover:text-slate-200 hover:bg-white/[0.03]' : 'border-white/8 bg-white/[0.03] text-slate-400 hover:border-white/15 hover:bg-white/[0.05] hover:text-slate-100'
} }
`} `}
> >
<i className={`fa-solid ${tab.icon} fa-fw text-xs ${isActive ? 'text-sky-400' : 'opacity-75'}`} /> <span className={`inline-flex h-9 w-9 items-center justify-center rounded-xl border text-sm ${isActive ? 'border-sky-300/20 bg-sky-400/10 text-sky-200' : 'border-white/10 bg-white/[0.04] text-slate-500 group-hover:text-slate-300'}`}>
<i className={`fa-solid ${tab.icon} fa-fw`} />
</span>
{tab.label} {tab.label}
{/* Active indicator bar */}
{isActive && ( {isActive && (
<span <span
className="absolute bottom-0 inset-x-0 h-0.5 rounded-full bg-sky-400 shadow-[0_0_8px_rgba(56,189,248,0.6)]" className="absolute inset-x-4 bottom-0 h-0.5 rounded-full bg-sky-300 shadow-[0_0_10px_rgba(125,211,252,0.8)]"
aria-hidden="true" aria-hidden="true"
/> />
)} )}
</button> </button>
) )
})} })}
</div> </div>
</nav> </nav>
</div>
) )
} }

View File

@@ -10,6 +10,100 @@ const SOCIAL_ICONS = {
website: { icon: 'fa-solid fa-link', label: 'Website' }, website: { icon: 'fa-solid fa-link', label: 'Website' },
} }
function formatNumber(value) {
return Number(value ?? 0).toLocaleString()
}
function formatRelativeDate(value) {
if (!value) return null
try {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
const now = new Date()
const diffSeconds = Math.round((date.getTime() - now.getTime()) / 1000)
const absSeconds = Math.abs(diffSeconds)
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
if (absSeconds < 3600) {
return formatter.format(Math.round(diffSeconds / 60), 'minute')
}
if (absSeconds < 86400) {
return formatter.format(Math.round(diffSeconds / 3600), 'hour')
}
if (absSeconds < 604800) {
return formatter.format(Math.round(diffSeconds / 86400), 'day')
}
if (absSeconds < 2629800) {
return formatter.format(Math.round(diffSeconds / 604800), 'week')
}
return formatter.format(Math.round(diffSeconds / 2629800), 'month')
} catch {
return null
}
}
function formatShortDate(value) {
if (!value) return null
try {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch {
return null
}
}
function truncateText(value, maxLength = 140) {
const text = String(value ?? '').trim()
if (!text) return ''
if (text.length <= maxLength) return text
return `${text.slice(0, maxLength).trimEnd()}...`
}
function buildInterestGroups(artworks = []) {
const categoryMap = new Map()
const contentTypeMap = new Map()
artworks.forEach((artwork) => {
const categoryKey = String(artwork?.category_slug || artwork?.category || '').trim().toLowerCase()
const categoryLabel = String(artwork?.category || '').trim()
const contentTypeKey = String(artwork?.content_type_slug || artwork?.content_type || '').trim().toLowerCase()
const contentTypeLabel = String(artwork?.content_type || '').trim()
if (categoryKey && categoryLabel) {
categoryMap.set(categoryKey, {
label: categoryLabel,
count: (categoryMap.get(categoryKey)?.count ?? 0) + 1,
})
}
if (contentTypeKey && contentTypeLabel) {
contentTypeMap.set(contentTypeKey, {
label: contentTypeLabel,
count: (contentTypeMap.get(contentTypeKey)?.count ?? 0) + 1,
})
}
})
const toSortedList = (source) => Array.from(source.values())
.sort((left, right) => right.count - left.count || left.label.localeCompare(right.label))
.slice(0, 5)
return {
categories: toSortedList(categoryMap),
contentTypes: toSortedList(contentTypeMap),
}
}
function InfoRow({ icon, label, children }) { function InfoRow({ icon, label, children }) {
return ( return (
<div className="flex items-start gap-3 py-2.5 border-b border-white/5 last:border-0"> <div className="flex items-start gap-3 py-2.5 border-b border-white/5 last:border-0">
@@ -22,11 +116,47 @@ function InfoRow({ icon, label, children }) {
) )
} }
function StatCard({ icon, label, value, tone = 'sky' }) {
const tones = {
sky: 'text-sky-300 bg-sky-400/10 border-sky-300/15',
amber: 'text-amber-200 bg-amber-300/10 border-amber-300/15',
emerald: 'text-emerald-200 bg-emerald-400/10 border-emerald-300/15',
violet: 'text-violet-200 bg-violet-400/10 border-violet-300/15',
}
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4 shadow-[0_18px_44px_rgba(2,6,23,0.18)]">
<div className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border ${tones[tone] || tones.sky}`}>
<i className={`fa-solid ${icon}`} />
</div>
<div className="mt-4 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
<div className="mt-1 text-2xl font-semibold tracking-tight text-white">{value}</div>
</div>
)
}
function SectionCard({ icon, eyebrow, title, children, className = '' }) {
return (
<section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_52px_rgba(2,6,23,0.18)] md:p-6 ${className}`.trim()}>
<div className="flex items-start gap-3">
<div className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.05] text-sky-300">
<i className={`${icon} text-base`} />
</div>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">{eyebrow}</p>
<h2 className="mt-1 text-xl font-semibold tracking-[-0.02em] text-white md:text-2xl">{title}</h2>
</div>
</div>
<div className="mt-5">{children}</div>
</section>
)
}
/** /**
* TabAbout * TabAbout
* Bio, social links, metadata - replaces old sidebar profile card. * Bio, social links, metadata - replaces old sidebar profile card.
*/ */
export default function TabAbout({ user, profile, socialLinks, countryName, followerCount }) { export default function TabAbout({ user, profile, stats, achievements, artworks, creatorStories, profileComments, socialLinks, countryName, followerCount, recentFollowers, leaderboardRank }) {
const uname = user.username || user.name const uname = user.username || user.name
const displayName = user.name || uname const displayName = user.name || uname
const about = profile?.about const about = profile?.about
@@ -47,119 +177,344 @@ export default function TabAbout({ user, profile, socialLinks, countryName, foll
const genderMap = { M: 'Male', F: 'Female', X: 'Non-binary / N/A' } const genderMap = { M: 'Male', F: 'Female', X: 'Non-binary / N/A' }
const genderLabel = genderMap[profile?.gender?.toUpperCase()] ?? null const genderLabel = genderMap[profile?.gender?.toUpperCase()] ?? null
const birthDate = profile?.birthdate
? (() => {
try {
return new Date(profile.birthdate).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
} catch { return null }
})()
: null
const lastSeenRelative = formatRelativeDate(user.last_visit_at)
const socialEntries = socialLinks const socialEntries = socialLinks
? Object.entries(socialLinks).filter(([, link]) => link?.url) ? Object.entries(socialLinks).filter(([, link]) => link?.url)
: [] : []
const followers = recentFollowers ?? []
const recentAchievements = Array.isArray(achievements?.recent) ? achievements.recent : []
const stories = Array.isArray(creatorStories) ? creatorStories : []
const comments = Array.isArray(profileComments) ? profileComments : []
const interestGroups = buildInterestGroups(Array.isArray(artworks) ? artworks : [])
const summaryCards = [
{ icon: 'fa-user-group', label: 'Followers', value: formatNumber(followerCount), tone: 'sky' },
{ icon: 'fa-images', label: 'Uploads', value: formatNumber(stats?.uploads_count ?? 0), tone: 'violet' },
{ icon: 'fa-eye', label: 'Profile views', value: formatNumber(stats?.profile_views_count ?? 0), tone: 'emerald' },
{ icon: 'fa-trophy', label: 'Weekly rank', value: leaderboardRank?.rank ? `#${formatNumber(leaderboardRank.rank)}` : 'Unranked', tone: 'amber' },
]
return ( return (
<div <div
id="tabpanel-about" id="tabpanel-about"
role="tabpanel" role="tabpanel"
aria-labelledby="tab-about" aria-labelledby="tab-about"
className="pt-6 max-w-2xl" className="mx-auto max-w-7xl px-4 pt-4 pb-10 md:px-6"
> >
{/* Bio */} <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{about ? ( {summaryCards.map((card) => (
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 mb-5 shadow-xl shadow-black/20 backdrop-blur"> <StatCard key={card.label} {...card} />
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2"> ))}
<i className="fa-solid fa-quote-left text-purple-400 fa-fw" />
About
</h2>
<p className="text-sm text-slate-300 leading-relaxed whitespace-pre-line">{about}</p>
</div>
) : (
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 mb-5 text-center text-slate-500 text-sm">
No bio yet.
</div>
)}
{/* Info card */}
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 mb-5 shadow-xl shadow-black/20">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2">
<i className="fa-solid fa-id-card text-sky-400 fa-fw" />
Profile Info
</h2>
<div className="divide-y divide-white/5">
{displayName && displayName !== uname && (
<InfoRow icon="fa-user" label="Display name">{displayName}</InfoRow>
)}
<InfoRow icon="fa-at" label="Username">
<span className="font-mono">@{uname}</span>
</InfoRow>
{genderLabel && (
<InfoRow icon="fa-venus-mars" label="Gender">{genderLabel}</InfoRow>
)}
{countryName && (
<InfoRow icon="fa-earth-americas" label="Country">
<span className="flex items-center gap-2">
{profile?.country_code && (
<img
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
alt={countryName}
className="w-4 h-auto rounded-sm"
onError={(e) => { e.target.style.display = 'none' }}
/>
)}
{countryName}
</span>
</InfoRow>
)}
{website && (
<InfoRow icon="fa-link" label="Website">
<a
href={website.startsWith('http') ? website : `https://${website}`}
target="_blank"
rel="nofollow noopener noreferrer"
className="text-sky-400 hover:text-sky-300 hover:underline transition-colors"
>
{(() => {
try {
const url = website.startsWith('http') ? website : `https://${website}`
return new URL(url).hostname
} catch { return website }
})()}
</a>
</InfoRow>
)}
{joinDate && (
<InfoRow icon="fa-calendar-days" label="Member since">{joinDate}</InfoRow>
)}
{lastVisit && (
<InfoRow icon="fa-clock" label="Last seen">{lastVisit}</InfoRow>
)}
<InfoRow icon="fa-users" label="Followers">{Number(followerCount).toLocaleString()}</InfoRow>
</div>
</div> </div>
{/* Social links */} <div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_380px]">
{socialEntries.length > 0 && ( <div className="space-y-6">
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20"> <SectionCard icon="fa-solid fa-circle-info" eyebrow="Profile story" title={`About ${displayName}`} className="bg-[linear-gradient(135deg,rgba(56,189,248,0.08),rgba(255,255,255,0.04),rgba(249,115,22,0.05))]">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2"> {about ? (
<i className="fa-solid fa-share-nodes text-sky-400 fa-fw" /> <p className="whitespace-pre-line text-[15px] leading-8 text-slate-200/90">{about}</p>
Social Links ) : (
</h2> <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-5 py-8 text-center text-sm text-slate-400">
<div className="flex flex-wrap gap-2"> This creator has not written a public bio yet.
{socialEntries.map(([platform, link]) => { </div>
const si = SOCIAL_ICONS[platform] ?? { icon: 'fa-solid fa-link', label: platform } )}
const href = link.url.startsWith('http') ? link.url : `https://${link.url}` </SectionCard>
return (
<a <SectionCard icon="fa-solid fa-address-card" eyebrow="Details" title="Profile information">
key={platform} <div className="grid gap-3 md:grid-cols-2">
href={href} {displayName && displayName !== uname ? (
target="_blank" <InfoRow icon="fa-user" label="Display name">{displayName}</InfoRow>
rel="nofollow noopener noreferrer" ) : null}
className="inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm border border-white/10 text-slate-300 hover:text-white hover:bg-white/8 hover:border-sky-400/30 transition-all" <InfoRow icon="fa-at" label="Username"><span className="font-mono">@{uname}</span></InfoRow>
aria-label={si.label} {genderLabel ? <InfoRow icon="fa-venus-mars" label="Gender">{genderLabel}</InfoRow> : null}
> {countryName ? (
<i className={`${si.icon} fa-fw`} /> <InfoRow icon="fa-earth-americas" label="Country">
<span>{si.label}</span> <span className="flex items-center gap-2">
</a> {profile?.country_code ? (
) <img
})} src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
</div> alt={countryName}
className="h-auto w-4 rounded-sm"
onError={(e) => { e.target.style.display = 'none' }}
/>
) : null}
{countryName}
</span>
</InfoRow>
) : null}
{website ? (
<InfoRow icon="fa-link" label="Website">
<a
href={website.startsWith('http') ? website : `https://${website}`}
target="_blank"
rel="nofollow noopener noreferrer"
className="text-sky-300 transition-colors hover:text-sky-200 hover:underline"
>
{(() => {
try {
const url = website.startsWith('http') ? website : `https://${website}`
return new URL(url).hostname
} catch { return website }
})()}
</a>
</InfoRow>
) : null}
{birthDate ? <InfoRow icon="fa-cake-candles" label="Birth date">{birthDate}</InfoRow> : null}
{joinDate ? <InfoRow icon="fa-calendar-days" label="Member since">{joinDate}</InfoRow> : null}
{lastVisit ? <InfoRow icon="fa-clock" label="Last seen">{lastSeenRelative ? `${lastSeenRelative} · ${lastVisit}` : lastVisit}</InfoRow> : null}
</div>
</SectionCard>
{followers.length > 0 ? (
<SectionCard icon="fa-solid fa-user-group" eyebrow="Community" title="Recent followers">
<div className="grid gap-3 sm:grid-cols-2">
{followers.slice(0, 6).map((follower) => (
<a
key={follower.id}
href={follower.profile_url ?? `/@${follower.username}`}
className="group flex items-center gap-3 rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-3 transition-colors hover:border-white/14 hover:bg-white/[0.06]"
>
<img
src={follower.avatar_url ?? '/images/avatar_default.webp'}
alt={follower.username}
className="h-11 w-11 rounded-2xl object-cover ring-1 ring-white/10 transition-all group-hover:ring-sky-400/30"
loading="lazy"
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-slate-200 group-hover:text-white">{follower.uname || follower.username}</div>
<div className="truncate text-xs text-slate-500">@{follower.username}</div>
</div>
</a>
))}
</div>
</SectionCard>
) : null}
{recentAchievements.length > 0 ? (
<SectionCard icon="fa-solid fa-trophy" eyebrow="Recent wins" title="Latest achievements">
<div className="grid gap-3 sm:grid-cols-2">
{recentAchievements.slice(0, 4).map((achievement) => (
<div
key={achievement.id}
className="rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-4 transition-colors hover:bg-white/[0.05]"
>
<div className="flex items-start gap-3">
<div className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-amber-300/15 bg-amber-300/10 text-amber-100">
<i className={`fa-solid ${achievement.icon || 'fa-trophy'}`} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-white">{achievement.name}</div>
{achievement.description ? (
<div className="mt-1 line-clamp-2 text-sm leading-relaxed text-slate-400">{achievement.description}</div>
) : null}
<div className="mt-3 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-300/75">
{achievement.unlocked_at ? (
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
{formatShortDate(achievement.unlocked_at) || 'Unlocked'}
</span>
) : null}
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
+{formatNumber(achievement.xp_reward ?? 0)} XP
</span>
</div>
</div>
</div>
</div>
))}
</div>
</SectionCard>
) : null}
{stories.length > 0 || comments.length > 0 ? (
<SectionCard icon="fa-solid fa-wave-square" eyebrow="Fresh from this creator" title="Recent activity">
<div className="grid gap-3 lg:grid-cols-2">
{stories.length > 0 ? (
<div className="rounded-[24px] border border-white/8 bg-white/[0.03] p-4">
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Latest story</div>
<span className="rounded-full border border-sky-300/15 bg-sky-400/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-sky-100/80">
{formatShortDate(stories[0]?.published_at) || 'Published'}
</span>
</div>
<a
href={`/stories/${stories[0].slug}`}
className="mt-3 block text-lg font-semibold tracking-tight text-white transition-colors hover:text-sky-200"
>
{stories[0].title}
</a>
{stories[0].excerpt ? (
<p className="mt-2 text-sm leading-7 text-slate-400">
{truncateText(stories[0].excerpt, 180)}
</p>
) : null}
<div className="mt-4 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-300/75">
{stories[0].reading_time ? (
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
{stories[0].reading_time} min read
</span>
) : null}
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
{formatNumber(stories[0].views ?? 0)} views
</span>
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
{formatNumber(stories[0].comments_count ?? 0)} comments
</span>
</div>
</div>
) : null}
{comments.length > 0 ? (
<div className="rounded-[24px] border border-white/8 bg-white/[0.03] p-4">
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Latest guestbook comment</div>
<span className="rounded-full border border-amber-300/15 bg-amber-300/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-amber-100/80">
{formatRelativeDate(comments[0]?.created_at) || 'Recently'}
</span>
</div>
<div className="mt-3 flex items-start gap-3">
<img
src={comments[0].author_avatar || '/images/avatar_default.webp'}
alt={comments[0].author_name}
className="h-11 w-11 rounded-2xl object-cover ring-1 ring-white/10"
loading="lazy"
onError={(e) => { e.target.src = '/images/avatar_default.webp' }}
/>
<div className="min-w-0 flex-1">
<a
href={comments[0].author_profile_url}
className="text-sm font-semibold text-white transition-colors hover:text-sky-200"
>
{comments[0].author_name}
</a>
<p className="mt-2 text-sm leading-7 text-slate-400">
{truncateText(comments[0].body, 180)}
</p>
</div>
</div>
</div>
) : null}
</div>
</SectionCard>
) : null}
</div> </div>
)}
<div className="space-y-6">
<SectionCard icon="fa-solid fa-sparkles" eyebrow="Creator snapshot" title="Profile snapshot" className="bg-[linear-gradient(180deg,rgba(15,23,42,0.72),rgba(2,6,23,0.5))]">
<div className="space-y-4">
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Creator level</div>
<div className="mt-2 flex items-end justify-between gap-4">
<div>
<div className="text-3xl font-semibold tracking-tight text-white">Lv {formatNumber(user?.level ?? 1)}</div>
<div className="mt-1 text-sm text-slate-400">{user?.rank || 'Creator'}</div>
</div>
<div className="rounded-2xl border border-sky-300/15 bg-sky-400/10 px-3 py-2 text-right">
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100/70">XP</div>
<div className="mt-1 text-lg font-semibold text-sky-100">{formatNumber(user?.xp ?? 0)}</div>
</div>
</div>
<div className="mt-4 h-2 overflow-hidden rounded-full bg-white/8">
<div className="h-full rounded-full bg-[linear-gradient(90deg,#38bdf8,#60a5fa,#f59e0b)]" style={{ width: `${Math.max(0, Math.min(100, Number(user?.progress_percent ?? 0)))}%` }} />
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Weekly rank</div>
<div className="mt-2 text-2xl font-semibold tracking-tight text-white">{leaderboardRank?.rank ? `#${formatNumber(leaderboardRank.rank)}` : 'Not ranked'}</div>
{leaderboardRank?.score ? <div className="mt-1 text-sm text-slate-400">Score {formatNumber(leaderboardRank.score)}</div> : null}
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Community size</div>
<div className="mt-2 text-2xl font-semibold tracking-tight text-white">{formatNumber(followerCount)}</div>
<div className="mt-1 text-sm text-slate-400">Followers</div>
</div>
</div>
</div>
</SectionCard>
<SectionCard icon="fa-solid fa-chart-simple" eyebrow="Highlights" title="Useful stats">
<div className="space-y-3">
<InfoRow icon="fa-images" label="Uploads">{formatNumber(stats?.uploads_count ?? 0)}</InfoRow>
<InfoRow icon="fa-eye" label="Artwork views received">{formatNumber(stats?.artwork_views_received_count ?? 0)}</InfoRow>
<InfoRow icon="fa-download" label="Downloads received">{formatNumber(stats?.downloads_received_count ?? 0)}</InfoRow>
<InfoRow icon="fa-heart" label="Favourites received">{formatNumber(stats?.favourites_received_count ?? 0)}</InfoRow>
<InfoRow icon="fa-comment" label="Comments received">{formatNumber(stats?.comments_received_count ?? 0)}</InfoRow>
</div>
</SectionCard>
{interestGroups.categories.length > 0 || interestGroups.contentTypes.length > 0 ? (
<SectionCard icon="fa-solid fa-layer-group" eyebrow="Creative focus" title="Favourite categories & formats">
<div className="space-y-5">
{interestGroups.categories.length > 0 ? (
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Top categories</div>
<div className="mt-3 flex flex-wrap gap-2.5">
{interestGroups.categories.map((category) => (
<span
key={category.label}
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-3.5 py-2 text-sm text-slate-200"
>
<span>{category.label}</span>
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[11px] font-semibold text-slate-400">{formatNumber(category.count)}</span>
</span>
))}
</div>
</div>
) : null}
{interestGroups.contentTypes.length > 0 ? (
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Preferred formats</div>
<div className="mt-3 flex flex-wrap gap-2.5">
{interestGroups.contentTypes.map((contentType) => (
<span
key={contentType.label}
className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/15 bg-sky-400/10 px-3.5 py-2 text-sm text-sky-100"
>
<span>{contentType.label}</span>
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[11px] font-semibold text-sky-100/70">{formatNumber(contentType.count)}</span>
</span>
))}
</div>
</div>
) : null}
</div>
</SectionCard>
) : null}
{socialEntries.length > 0 ? (
<SectionCard icon="fa-solid fa-share-nodes" eyebrow="Links" title="Social links">
<div className="flex flex-wrap gap-2.5">
{socialEntries.map(([platform, link]) => {
const si = SOCIAL_ICONS[platform] ?? { icon: 'fa-solid fa-link', label: platform }
const href = link.url.startsWith('http') ? link.url : `https://${link.url}`
return (
<a
key={platform}
href={href}
target="_blank"
rel="nofollow noopener noreferrer"
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-3.5 py-2.5 text-sm text-slate-300 transition-all hover:border-sky-400/30 hover:bg-white/[0.07] hover:text-white"
aria-label={si.label}
>
<i className={`${si.icon} fa-fw`} />
<span>{si.label}</span>
</a>
)
})}
</div>
</SectionCard>
) : null}
</div>
</div>
</div> </div>
) )
} }

View File

@@ -1,20 +1,286 @@
import React from 'react' import React, { useEffect, useMemo, useState } from 'react'
import ProfileGalleryPanel from '../ProfileGalleryPanel' import ArtworkGallery from '../../artwork/ArtworkGallery'
export default function TabArtworks({ artworks, featuredArtworks, username, isActive }) { function slugify(value) {
return String(value ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
function formatNumber(value) {
return Number(value ?? 0).toLocaleString()
}
function sortByPublishedAt(items) {
return [...items].sort((left, right) => {
const leftTime = left?.published_at ? new Date(left.published_at).getTime() : 0
const rightTime = right?.published_at ? new Date(right.published_at).getTime() : 0
return rightTime - leftTime
})
}
function isWallpaperArtwork(item) {
const contentType = String(item?.content_type_slug || item?.content_type || '').toLowerCase()
const category = String(item?.category_slug || item?.category || '').toLowerCase()
return contentType.includes('wallpaper') || category.includes('wallpaper')
}
function useArtworkPreview(username, sort) {
const [items, setItems] = useState([])
useEffect(() => {
let active = true
async function load() {
try {
const response = await fetch(`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`, {
headers: { Accept: 'application/json' },
})
if (!response.ok) return
const data = await response.json()
if (active) {
setItems(Array.isArray(data?.data) ? data.data : [])
}
} catch (_) {}
}
load()
return () => {
active = false
}
}, [sort, username])
return items
}
function SectionHeader({ eyebrow, title, description, action }) {
return (
<div className="mb-5 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-300/80">{eyebrow}</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white md:text-3xl">{title}</h2>
{description ? <p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-400">{description}</p> : null}
</div>
{action}
</div>
)
}
function artworkMeta(art) {
return [art?.content_type, art?.category].filter(Boolean).join(' • ')
}
function artworkStats(art) {
return [
{ label: 'Views', value: formatNumber(art?.views ?? 0), icon: 'fa-regular fa-eye' },
{ label: 'Likes', value: formatNumber(art?.likes ?? 0), icon: 'fa-regular fa-heart' },
{ label: 'Downloads', value: formatNumber(art?.downloads ?? 0), icon: 'fa-solid fa-download' },
]
}
function FeaturedShowcase({ featuredArtworks }) {
if (!featuredArtworks?.length) return null
const leadArtwork = featuredArtworks[0]
const secondaryArtworks = featuredArtworks.slice(1, 4)
const leadMeta = artworkMeta(leadArtwork)
const leadStats = artworkStats(leadArtwork)
return (
<section className="relative mt-8 overflow-hidden rounded-[36px] border border-white/10 bg-[linear-gradient(135deg,rgba(56,189,248,0.14),rgba(255,255,255,0.04),rgba(249,115,22,0.12))] shadow-[0_30px_90px_rgba(2,6,23,0.3)]">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(250,204,21,0.12),transparent_30%),radial-gradient(circle_at_bottom_left,rgba(56,189,248,0.14),transparent_34%)]" />
<div className="relative grid gap-6 p-5 md:p-7 xl:grid-cols-[minmax(0,1.28fr)_380px]">
<a
href={`/art/${leadArtwork.id}/${slugify(leadArtwork.name)}`}
className="group relative overflow-hidden rounded-[30px] border border-white/10 bg-slate-950/60 shadow-[0_24px_60px_rgba(2,6,23,0.28)]"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.24),transparent_46%),linear-gradient(to_top,rgba(2,6,23,0.9),rgba(2,6,23,0.08))]" />
<div className="aspect-[16/9] overflow-hidden">
<img
src={leadArtwork.thumb}
alt={leadArtwork.name}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.05]"
loading="lazy"
/>
</div>
<div className="absolute inset-x-0 top-0 flex items-start justify-between p-5 md:p-7">
<div className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100 backdrop-blur-sm">
<i className="fa-solid fa-star text-[10px]" />
Featured spotlight
</div>
<div className="hidden rounded-2xl border border-white/10 bg-slate-950/35 px-4 py-3 backdrop-blur-md md:block">
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">Featured set</div>
<div className="mt-1 text-2xl font-semibold tracking-tight text-white">{formatNumber(featuredArtworks.length)}</div>
</div>
</div>
<div className="absolute inset-x-0 bottom-0 p-5 md:p-7">
{leadMeta ? (
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/85">{leadMeta}</div>
) : null}
<h2 className="mt-3 max-w-2xl text-2xl font-semibold tracking-[-0.04em] text-white md:text-[2.7rem] md:leading-[1.02]">
{leadArtwork.name}
</h2>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-slate-200/90 md:text-[15px]">
A standout first impression for the artwork landing page, built to pull attention before visitors move into trending picks and the full archive.
</p>
<div className="mt-5 flex flex-wrap gap-2.5">
<span className="rounded-full border border-white/15 bg-black/20 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-100/90">Top pick</span>
{leadArtwork.width && leadArtwork.height ? (
<span className="rounded-full border border-white/15 bg-black/20 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-100/90">
{leadArtwork.width}x{leadArtwork.height}
</span>
) : null}
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-3">
{leadStats.map((stat) => (
<div key={stat.label} className="rounded-2xl border border-white/10 bg-slate-950/35 px-4 py-3 backdrop-blur-md">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300/75">
<i className={`${stat.icon} text-[10px]`} />
{stat.label}
</div>
<div className="mt-1 text-xl font-semibold tracking-tight text-white">{stat.value}</div>
</div>
))}
</div>
</div>
</a>
<div className="flex flex-col gap-4">
<div className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.66),rgba(2,6,23,0.5))] p-5 backdrop-blur-sm">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Featured</p>
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Curated gallery highlights</h3>
<p className="mt-2 text-sm leading-relaxed text-slate-300">
These picks create a cleaner visual entry point and give the artwork page more personality than a simple list of thumbnails.
</p>
<div className="mt-4 flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200/85">Editorial layout</span>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200/85">Hero-led showcase</span>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
{secondaryArtworks.map((art, index) => (
<a
key={art.id}
href={`/art/${art.id}/${slugify(art.name)}`}
className="group flex gap-4 rounded-[26px] border border-white/10 bg-white/[0.045] p-4 shadow-[0_14px_36px_rgba(2,6,23,0.18)] transition-all hover:-translate-y-0.5 hover:bg-white/[0.08]"
>
<div className="h-24 w-28 shrink-0 overflow-hidden rounded-[18px] bg-black/30 ring-1 ring-white/10">
<img
src={art.thumb}
alt={art.name}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04]"
loading="lazy"
/>
</div>
<div className="min-w-0">
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Feature {index + 2}</div>
{artworkMeta(art) ? <div className="truncate text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100/70">{artworkMeta(art)}</div> : null}
</div>
<div className="mt-2 truncate text-lg font-semibold text-white">{art.name}</div>
{art.label ? <div className="mt-1 line-clamp-2 text-sm leading-relaxed text-slate-400">{art.label}</div> : null}
<div className="mt-3 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-300/80">
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">{formatNumber(art?.views ?? 0)} views</span>
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">{formatNumber(art?.likes ?? 0)} likes</span>
</div>
</div>
</a>
))}
</div>
</div>
</div>
</section>
)
}
function PreviewRail({ eyebrow, title, description, items }) {
if (!items.length) return null
return (
<section className="mt-10">
<SectionHeader eyebrow={eyebrow} title={title} description={description} />
<ArtworkGallery
items={items}
compact
className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-4"
resolveCardProps={() => ({ showActions: false })}
/>
</section>
)
}
function FullGalleryCta({ galleryUrl, username }) {
return (
<section className="mt-10 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 md:p-8">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-300/80">Full archive</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white md:text-3xl">Want the complete gallery?</h2>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-400">
The curated sections above are a friendlier starting point. The full gallery has the infinite-scroll archive with everything published by @{username}.
</p>
</div>
<a
href={galleryUrl || '#'}
className="inline-flex items-center gap-2 self-start rounded-2xl border border-sky-300/25 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition-colors hover:bg-sky-400/15"
>
<i className="fa-solid fa-arrow-right fa-fw" />
Browse full gallery
</a>
</div>
</section>
)
}
export default function TabArtworks({ artworks, featuredArtworks, username, galleryUrl }) {
const initialItems = artworks?.data ?? artworks ?? []
const trendingItems = useArtworkPreview(username, 'trending')
const popularItems = useArtworkPreview(username, 'views')
const wallpaperItems = useMemo(() => {
const wallpapers = popularItems.filter(isWallpaperArtwork)
return (wallpapers.length ? wallpapers : popularItems).slice(0, 4)
}, [popularItems])
const latestItems = useMemo(() => sortByPublishedAt(initialItems).slice(0, 4), [initialItems])
return ( return (
<div <div
id="tabpanel-artworks" id="tabpanel-artworks"
role="tabpanel" role="tabpanel"
aria-labelledby="tab-artworks" aria-labelledby="tab-artworks"
className="pt-6" className="mx-auto max-w-7xl px-4 pt-2 pb-10 md:px-6"
> >
<ProfileGalleryPanel <FeaturedShowcase featuredArtworks={featuredArtworks ?? []} />
artworks={artworks}
featuredArtworks={featuredArtworks} <PreviewRail
username={username} eyebrow="Trending"
title="Trending artworks right now"
description="A quick scan of the work currently pulling the most momentum on the creator profile."
items={trendingItems.slice(0, 4)}
/> />
<PreviewRail
eyebrow="Wallpaper picks"
title="Popular wallpapers"
description="Surface the strongest wallpaper-friendly pieces before sending people into the full archive."
items={wallpaperItems}
/>
<PreviewRail
eyebrow="Latest"
title="Recent additions"
description="Fresh uploads from the profile, presented as a preview instead of the full endless gallery."
items={latestItems}
/>
<FullGalleryCta galleryUrl={galleryUrl} username={username} />
</div> </div>
) )
} }

View File

@@ -5,24 +5,50 @@ import PostComposer from '../../Feed/PostComposer'
import PostCardSkeleton from '../../Feed/PostCardSkeleton' import PostCardSkeleton from '../../Feed/PostCardSkeleton'
import FeedSidebar from '../../Feed/FeedSidebar' import FeedSidebar from '../../Feed/FeedSidebar'
function formatCompactNumber(value) {
return Number(value ?? 0).toLocaleString()
}
function EmptyPostsState({ isOwner, username }) { function EmptyPostsState({ isOwner, username }) {
return ( return (
<div className="flex flex-col items-center justify-center py-20 text-center"> <div className="flex flex-col items-center justify-center rounded-[28px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-20 text-center">
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600"> <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-3xl border border-white/10 bg-white/[0.04] text-slate-500">
<i className="fa-regular fa-newspaper text-2xl" /> <i className="fa-regular fa-newspaper text-2xl" />
</div> </div>
<p className="text-slate-400 font-medium mb-1">No posts yet</p> <p className="mb-1 text-lg font-semibold text-white">No posts yet</p>
{isOwner ? ( {isOwner ? (
<p className="text-slate-600 text-sm max-w-xs"> <p className="max-w-sm text-sm leading-relaxed text-slate-400">
Share updates or showcase your artworks. Share works in progress, announce releases, or add a bit of personality beyond the gallery.
</p> </p>
) : ( ) : (
<p className="text-slate-600 text-sm">@{username} has not posted anything yet.</p> <p className="max-w-sm text-sm leading-relaxed text-slate-400">@{username} has not published any profile posts yet.</p>
)} )}
</div> </div>
) )
} }
function ErrorPostsState({ onRetry }) {
return (
<div className="rounded-[28px] border border-rose-400/20 bg-rose-400/10 px-6 py-12 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl border border-rose-300/20 bg-rose-500/10 text-rose-200">
<i className="fa-solid fa-triangle-exclamation text-lg" />
</div>
<h3 className="mt-4 text-lg font-semibold text-white">Posts could not be loaded</h3>
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-rose-100/80">
The profile shell loaded, but the posts feed request failed. Retry without leaving the page.
</p>
<button
type="button"
onClick={onRetry}
className="mt-5 inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-white/10 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-white/15"
>
<i className="fa-solid fa-rotate-right" />
Retry loading posts
</button>
</div>
)
}
/** /**
* TabPosts * TabPosts
* Profile Posts tab — shows the user's post feed with optional composer (for owner). * Profile Posts tab — shows the user's post feed with optional composer (for owner).
@@ -51,6 +77,7 @@ export default function TabPosts({
recentFollowers, recentFollowers,
socialLinks, socialLinks,
countryName, countryName,
profileUrl,
onTabChange, onTabChange,
}) { }) {
const [posts, setPosts] = useState([]) const [posts, setPosts] = useState([])
@@ -58,21 +85,22 @@ export default function TabPosts({
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(false) const [hasMore, setHasMore] = useState(false)
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [error, setError] = useState(false)
// Fetch on mount
React.useEffect(() => { React.useEffect(() => {
fetchFeed(1) fetchFeed(1)
}, [username]) }, [username])
const fetchFeed = async (p = 1) => { const fetchFeed = async (p = 1) => {
setLoading(true) setLoading(true)
setError(false)
try { try {
const { data } = await axios.get(`/api/posts/profile/${username}`, { params: { page: p } }) const { data } = await axios.get(`/api/posts/profile/${username}`, { params: { page: p } })
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data]) setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
setHasMore(data.meta.current_page < data.meta.last_page) setHasMore(data.meta.current_page < data.meta.last_page)
setPage(p) setPage(p)
} catch { } catch {
// setError(true)
} finally { } finally {
setLoading(false) setLoading(false)
setLoaded(true) setLoaded(true)
@@ -87,28 +115,94 @@ export default function TabPosts({
setPosts((prev) => prev.filter((p) => p.id !== postId)) setPosts((prev) => prev.filter((p) => p.id !== postId))
}, []) }, [])
const summaryCards = [
{ label: 'Followers', value: formatCompactNumber(followerCount), icon: 'fa-user-group' },
{ label: 'Artworks', value: formatCompactNumber(stats?.uploads_count ?? 0), icon: 'fa-image' },
{ label: 'Awards', value: formatCompactNumber(stats?.awards_received_count ?? 0), icon: 'fa-trophy' },
{ label: 'Location', value: countryName || 'Unknown', icon: 'fa-location-dot' },
]
return ( return (
<div className="flex gap-6 py-4 items-start"> <div className="py-6">
{/* ── Main feed column ──────────────────────────────────────────────── */} <div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_20rem]">
<div className="flex-1 min-w-0 space-y-4"> <section className="rounded-[30px] border border-white/10 bg-[linear-gradient(135deg,rgba(56,189,248,0.08),rgba(255,255,255,0.04),rgba(249,115,22,0.06))] p-6 shadow-[0_20px_60px_rgba(2,6,23,0.24)]">
{/* Composer (owner only) */} <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Profile posts</p>
<h2 className="mt-2 text-3xl font-semibold tracking-[-0.03em] text-white">
Updates, thoughts, and shared work from @{username}
</h2>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-slate-300">
This stream adds the human layer to the profile: quick notes, shared artwork posts, and announcements that do not belong inside the gallery grid.
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => onTabChange?.('artworks')}
className="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/[0.06] px-4 py-2.5 text-sm font-medium text-slate-200 transition-colors hover:bg-white/[0.1]"
>
<i className="fa-solid fa-images fa-fw" />
View artworks
</button>
<button
type="button"
onClick={() => onTabChange?.('about')}
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm font-medium text-slate-300 transition-colors hover:bg-white/[0.08] hover:text-white"
>
<i className="fa-solid fa-id-card fa-fw" />
About creator
</button>
{profileUrl ? (
<a
href={profileUrl}
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm font-medium text-slate-300 transition-colors hover:bg-white/[0.08] hover:text-white"
>
<i className="fa-solid fa-user fa-fw" />
Canonical profile
</a>
) : null}
</div>
</div>
</section>
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:grid-cols-2">
{summaryCards.map((card) => (
<div
key={card.label}
className="rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
>
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{card.label}</div>
<i className={`fa-solid ${card.icon} text-slate-500`} />
</div>
<div className="mt-3 text-xl font-semibold tracking-tight text-white">{card.value}</div>
</div>
))}
</section>
</div>
<div className="mt-6 grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]">
<div className="min-w-0 space-y-4">
{isOwner && authUser && ( {isOwner && authUser && (
<PostComposer user={authUser} onPosted={handlePosted} /> <PostComposer user={authUser} onPosted={handlePosted} />
)} )}
{/* Skeletons while loading */}
{!loaded && loading && ( {!loaded && loading && (
<div className="space-y-4"> <div className="space-y-4">
{[1, 2, 3].map((i) => <PostCardSkeleton key={i} />)} {[1, 2, 3].map((i) => <PostCardSkeleton key={i} />)}
</div> </div>
)} )}
{/* Empty state */} {loaded && error && posts.length === 0 && (
{loaded && !loading && posts.length === 0 && ( <ErrorPostsState onRetry={() => fetchFeed(1)} />
)}
{loaded && !loading && !error && posts.length === 0 && (
<EmptyPostsState isOwner={isOwner} username={username} /> <EmptyPostsState isOwner={isOwner} username={username} />
)} )}
{/* Post list */}
{posts.length > 0 && ( {posts.length > 0 && (
<div className="space-y-4"> <div className="space-y-4">
{posts.map((post) => ( {posts.map((post) => (
@@ -123,13 +217,12 @@ export default function TabPosts({
</div> </div>
)} )}
{/* Load more */}
{loaded && hasMore && ( {loaded && hasMore && (
<div className="flex justify-center py-4"> <div className="flex justify-center py-4">
<button <button
onClick={() => fetchFeed(page + 1)} onClick={() => fetchFeed(page + 1)}
disabled={loading} disabled={loading}
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50" className="rounded-2xl border border-white/10 bg-white/[0.04] px-6 py-2.5 text-sm font-medium text-slate-200 transition-colors hover:bg-white/[0.08] disabled:opacity-50"
> >
{loading ? ( {loading ? (
<><i className="fa-solid fa-spinner fa-spin mr-2" />Loading</> <><i className="fa-solid fa-spinner fa-spin mr-2" />Loading</>
@@ -137,22 +230,22 @@ export default function TabPosts({
</button> </button>
</div> </div>
)} )}
</div> </div>
{/* ── Sidebar ───────────────────────────────────────────────────────── */} <aside className="hidden xl:block xl:sticky xl:top-24">
<aside className="w-72 xl:w-80 shrink-0 hidden lg:block sticky top-20 self-start"> <FeedSidebar
<FeedSidebar user={user}
user={user} profile={profile}
profile={profile} stats={stats}
stats={stats} followerCount={followerCount}
followerCount={followerCount} recentFollowers={recentFollowers}
recentFollowers={recentFollowers} socialLinks={socialLinks}
socialLinks={socialLinks} countryName={countryName}
countryName={countryName} isLoggedIn={!!authUser}
isLoggedIn={!!authUser} onTabChange={onTabChange}
onTabChange={onTabChange} />
/> </aside>
</aside> </div>
</div> </div>
) )
} }

View File

@@ -0,0 +1,88 @@
import React, { useEffect, useMemo, useState } from 'react'
import { getEcho } from '../../bootstrap'
export default function MessageInboxBadge({ initialUnreadCount = 0, userId = null, href = '/messages' }) {
const [unreadCount, setUnreadCount] = useState(Math.max(0, Number(initialUnreadCount || 0)))
useEffect(() => {
let cancelled = false
const loadUnreadState = async () => {
try {
const response = await fetch('/api/messages/conversations', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
})
if (!response.ok) {
throw new Error('Failed to load unread conversations')
}
const payload = await response.json()
if (cancelled) {
return
}
if (cancelled) {
return
}
const nextUnreadTotal = Number(payload?.summary?.unread_total)
if (Number.isFinite(nextUnreadTotal)) {
setUnreadCount(Math.max(0, nextUnreadTotal))
}
} catch {
// Keep server-rendered count if bootstrap fetch fails.
}
}
loadUnreadState()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!userId) {
return undefined
}
const echo = getEcho()
if (!echo) {
return undefined
}
const channel = echo.private(`user.${userId}`)
const handleConversationUpdated = (payload) => {
const nextUnreadTotal = Number(payload?.summary?.unread_total)
if (Number.isFinite(nextUnreadTotal)) {
setUnreadCount(Math.max(0, nextUnreadTotal))
}
}
channel.listen('.conversation.updated', handleConversationUpdated)
return () => {
channel.stopListening('.conversation.updated', handleConversationUpdated)
echo.leaveChannel(`private-user.${userId}`)
}
}, [userId])
return (
<a
href={href}
className="relative w-10 h-10 inline-flex items-center justify-center rounded-lg hover:bg-white/5"
title="Messages"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
{unreadCount > 0 ? (
<span className="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
) : null}
</a>
)
}

View File

@@ -87,6 +87,23 @@ function mountToolbarNotifications() {
}); });
} }
function mountToolbarMessages() {
var rootEl = document.getElementById('toolbar-messages-root');
if (!rootEl || rootEl.dataset.reactMounted === 'true') return;
var props = safeParseJson(rootEl.getAttribute('data-props'), {});
rootEl.dataset.reactMounted = 'true';
void import('./components/social/MessageInboxBadge.jsx')
.then(function (module) {
var Component = module.default;
createRoot(rootEl).render(React.createElement(Component, props));
})
.catch(function () {
rootEl.dataset.reactMounted = 'false';
});
}
function mountStorySocial() { function mountStorySocial() {
var socialRoot = document.getElementById('story-social-root'); var socialRoot = document.getElementById('story-social-root');
if (socialRoot && socialRoot.dataset.reactMounted !== 'true') { if (socialRoot && socialRoot.dataset.reactMounted !== 'true') {
@@ -130,6 +147,7 @@ function mountStorySocial() {
}); });
} }
mountToolbarMessages();
mountToolbarNotifications(); mountToolbarNotifications();
mountStorySocial(); mountStorySocial();

View File

@@ -202,16 +202,14 @@
@endif @endif
</a> </a>
<a href="{{ Route::has('messages.index') ? route('messages.index') : '/messages' }}" @php
class="relative w-10 h-10 inline-flex items-center justify-center rounded-lg hover:bg-white/5" $toolbarMessagesProps = [
title="Messages"> 'initialUnreadCount' => (int) ($msgCount ?? 0),
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 'userId' => (int) ($userId ?? Auth::id() ?? 0),
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /> 'href' => Route::has('messages.index') ? route('messages.index') : '/messages',
</svg> ];
@if(($msgCount ?? 0) > 0) @endphp
<span class="absolute -bottom-1 right-0 text-[11px] tabular-nums px-1.5 py-0.5 rounded bg-red-700/70 text-white border border-sb-line">{{ $msgCount }}</span> <div id="toolbar-messages-root" data-props='@json($toolbarMessagesProps)'></div>
@endif
</a>
<div id="toolbar-notification-root" data-props='@json(['initialUnreadCount' => (int) ($noticeCount ?? 0)])'></div> <div id="toolbar-notification-root" data-props='@json(['initialUnreadCount' => (int) ($noticeCount ?? 0)])'></div>
</div> </div>

View File

@@ -490,6 +490,10 @@ Route::middleware(['web', 'auth', 'normalize.username', 'throttle:60,1'])
->prefix('messages') ->prefix('messages')
->name('api.messages.') ->name('api.messages.')
->group(function () { ->group(function () {
Route::post('presence/heartbeat', [\App\Http\Controllers\Api\Messaging\PresenceController::class, 'heartbeat'])
->middleware('throttle:messages-presence')
->name('presence.heartbeat');
Route::get('settings', [\App\Http\Controllers\Api\Messaging\MessagingSettingsController::class, 'show'])->name('settings.show'); Route::get('settings', [\App\Http\Controllers\Api\Messaging\MessagingSettingsController::class, 'show'])->name('settings.show');
Route::patch('settings', [\App\Http\Controllers\Api\Messaging\MessagingSettingsController::class, 'update'])->name('settings.update'); Route::patch('settings', [\App\Http\Controllers\Api\Messaging\MessagingSettingsController::class, 'update'])->name('settings.update');
@@ -497,7 +501,7 @@ Route::middleware(['web', 'auth', 'normalize.username', 'throttle:60,1'])
Route::post('conversation', [\App\Http\Controllers\Api\Messaging\ConversationController::class, 'store'])->middleware('throttle:messages-send')->name('conversations.store'); Route::post('conversation', [\App\Http\Controllers\Api\Messaging\ConversationController::class, 'store'])->middleware('throttle:messages-send')->name('conversations.store');
Route::get('conversation/{id}', [\App\Http\Controllers\Api\Messaging\ConversationController::class, 'show'])->whereNumber('id')->name('conversations.show'); Route::get('conversation/{id}', [\App\Http\Controllers\Api\Messaging\ConversationController::class, 'show'])->whereNumber('id')->name('conversations.show');
Route::post('{conversation_id}/read', [\App\Http\Controllers\Api\Messaging\ConversationController::class, 'markRead'])->whereNumber('conversation_id')->name('read'); Route::post('{conversation_id}/read', [\App\Http\Controllers\Api\Messaging\ConversationController::class, 'markRead'])->middleware('throttle:messages-read')->whereNumber('conversation_id')->name('read');
Route::post('{conversation_id}/archive', [\App\Http\Controllers\Api\Messaging\ConversationController::class, 'archive'])->whereNumber('conversation_id')->name('archive'); Route::post('{conversation_id}/archive', [\App\Http\Controllers\Api\Messaging\ConversationController::class, 'archive'])->whereNumber('conversation_id')->name('archive');
Route::post('{conversation_id}/mute', [\App\Http\Controllers\Api\Messaging\ConversationController::class, 'mute'])->whereNumber('conversation_id')->name('mute'); Route::post('{conversation_id}/mute', [\App\Http\Controllers\Api\Messaging\ConversationController::class, 'mute'])->whereNumber('conversation_id')->name('mute');
Route::post('{conversation_id}/pin', [\App\Http\Controllers\Api\Messaging\ConversationController::class, 'pin'])->whereNumber('conversation_id')->name('pin'); Route::post('{conversation_id}/pin', [\App\Http\Controllers\Api\Messaging\ConversationController::class, 'pin'])->whereNumber('conversation_id')->name('pin');
@@ -510,12 +514,13 @@ Route::middleware(['web', 'auth', 'normalize.username', 'throttle:60,1'])
Route::get('search', [\App\Http\Controllers\Api\Messaging\MessageSearchController::class, 'index'])->name('search.index'); Route::get('search', [\App\Http\Controllers\Api\Messaging\MessageSearchController::class, 'index'])->name('search.index');
Route::post('search/rebuild', [\App\Http\Controllers\Api\Messaging\MessageSearchController::class, 'rebuild'])->name('search.rebuild'); Route::post('search/rebuild', [\App\Http\Controllers\Api\Messaging\MessageSearchController::class, 'rebuild'])->name('search.rebuild');
Route::get('{conversation_id}/delta', [\App\Http\Controllers\Api\Messaging\MessageController::class, 'delta'])->middleware('throttle:messages-recovery')->whereNumber('conversation_id')->name('messages.delta');
Route::get('{conversation_id}', [\App\Http\Controllers\Api\Messaging\MessageController::class, 'index'])->whereNumber('conversation_id')->name('messages.index'); Route::get('{conversation_id}', [\App\Http\Controllers\Api\Messaging\MessageController::class, 'index'])->whereNumber('conversation_id')->name('messages.index');
Route::post('{conversation_id}', [\App\Http\Controllers\Api\Messaging\MessageController::class, 'store'])->middleware('throttle:messages-send')->whereNumber('conversation_id')->name('messages.store'); Route::post('{conversation_id}', [\App\Http\Controllers\Api\Messaging\MessageController::class, 'store'])->middleware('throttle:messages-send')->whereNumber('conversation_id')->name('messages.store');
Route::post('{conversation_id}/typing', [\App\Http\Controllers\Api\Messaging\TypingController::class, 'start'])->whereNumber('conversation_id')->name('typing.start'); Route::post('{conversation_id}/typing', [\App\Http\Controllers\Api\Messaging\TypingController::class, 'start'])->middleware('throttle:messages-typing')->whereNumber('conversation_id')->name('typing.start');
Route::post('{conversation_id}/typing/stop', [\App\Http\Controllers\Api\Messaging\TypingController::class, 'stop'])->whereNumber('conversation_id')->name('typing.stop'); Route::post('{conversation_id}/typing/stop', [\App\Http\Controllers\Api\Messaging\TypingController::class, 'stop'])->middleware('throttle:messages-typing')->whereNumber('conversation_id')->name('typing.stop');
Route::get('{conversation_id}/typing', [\App\Http\Controllers\Api\Messaging\TypingController::class, 'index'])->whereNumber('conversation_id')->name('typing.index'); Route::get('{conversation_id}/typing', [\App\Http\Controllers\Api\Messaging\TypingController::class, 'index'])->middleware('throttle:messages-typing')->whereNumber('conversation_id')->name('typing.index');
Route::post('{conversation_id}/{message_id}/react', [\App\Http\Controllers\Api\Messaging\MessageController::class, 'react'])->whereNumber(['conversation_id', 'message_id'])->name('react'); Route::post('{conversation_id}/{message_id}/react', [\App\Http\Controllers\Api\Messaging\MessageController::class, 'react'])->whereNumber(['conversation_id', 'message_id'])->name('react');
Route::delete('{conversation_id}/{message_id}/react', [\App\Http\Controllers\Api\Messaging\MessageController::class, 'unreact'])->whereNumber(['conversation_id', 'message_id'])->name('unreact'); Route::delete('{conversation_id}/{message_id}/react', [\App\Http\Controllers\Api\Messaging\MessageController::class, 'unreact'])->whereNumber(['conversation_id', 'message_id'])->name('unreact');

View File

@@ -2,6 +2,7 @@
use App\Models\Conversation; use App\Models\Conversation;
use App\Policies\ConversationPolicy; use App\Policies\ConversationPolicy;
use App\Services\Messaging\MessagingPayloadFactory;
use Illuminate\Support\Facades\Broadcast; use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('App.Models.User.{id}', function ($user, $id) { Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
@@ -43,10 +44,9 @@ Broadcast::channel('presence-conversation.{conversationId}', function ($user, $c
return false; return false;
} }
return [ return app(MessagingPayloadFactory::class)->presenceUser($user);
'id' => (int) $user->id, });
'username' => (string) $user->username,
'display_name' => (string) ($user->name ?: $user->username), Broadcast::channel('presence-messaging', function ($user) {
'avatar_thumb_url' => null, return app(MessagingPayloadFactory::class)->presenceUser($user);
];
}); });

View File

@@ -133,3 +133,8 @@ Schedule::command('forum:firewall-scan')
->name('forum-firewall-scan') ->name('forum-firewall-scan')
->withoutOverlapping() ->withoutOverlapping()
->runInBackground(); ->runInBackground();
Schedule::command('horizon:snapshot')
->everyFiveMinutes()
->name('horizon-snapshot')
->withoutOverlapping();

View File

@@ -236,6 +236,11 @@ Route::get('/@{username}/gallery', [ProfileController::class, 'showGalleryByUser
->where('username', '[A-Za-z0-9_-]{3,20}') ->where('username', '[A-Za-z0-9_-]{3,20}')
->name('profile.gallery'); ->name('profile.gallery');
Route::get('/@{username}/{tab}', [ProfileController::class, 'showTabByUsername'])
->where('username', '[A-Za-z0-9_-]{3,20}')
->where('tab', 'posts|artworks|stories|achievements|collections|about|stats|favourites|activity')
->name('profile.tab');
Route::get('/@{username}', [ProfileController::class, 'showByUsername']) Route::get('/@{username}', [ProfileController::class, 'showByUsername'])
->where('username', '[A-Za-z0-9_-]{3,20}') ->where('username', '[A-Za-z0-9_-]{3,20}')
->name('profile.show'); ->name('profile.show');

View File

@@ -12,8 +12,10 @@ use App\Policies\ConversationPolicy;
use App\Events\ConversationUpdated; use App\Events\ConversationUpdated;
use App\Events\MessageCreated; use App\Events\MessageCreated;
use App\Events\MessageRead; use App\Events\MessageRead;
use App\Services\Messaging\MessagingPresenceService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -259,6 +261,133 @@ test('typing endpoints reject non participants', function () {
->assertStatus(403); ->assertStatus(403);
}); });
test('conversation list includes unread summary total', function () {
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
Message::create([
'conversation_id' => $conv->id,
'sender_id' => $userB->id,
'body' => 'Unread one',
]);
Message::create([
'conversation_id' => $conv->id,
'sender_id' => $userB->id,
'body' => 'Unread two',
]);
$response = $this->actingAs($userA)->getJson('/api/messages/conversations');
$response->assertStatus(200)
->assertJsonPath('summary.unread_total', 2);
});
test('conversation updated broadcast includes unread summary total', function () {
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
Message::create([
'conversation_id' => $conv->id,
'sender_id' => $userB->id,
'body' => 'Unread one',
]);
Message::create([
'conversation_id' => $conv->id,
'sender_id' => $userB->id,
'body' => 'Unread two',
]);
$payload = (new ConversationUpdated($userA->id, $conv->fresh(), 'message.created'))->broadcastWith();
expect($payload['reason'])->toBe('message.created')
->and((int) data_get($payload, 'conversation.id'))->toBe($conv->id)
->and((int) data_get($payload, 'conversation.unread_count'))->toBe(2)
->and((int) data_get($payload, 'summary.unread_total'))->toBe(2);
});
test('delta endpoint returns only messages after requested id in ascending order', function () {
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
$first = Message::create([
'conversation_id' => $conv->id,
'sender_id' => $userB->id,
'body' => 'First',
]);
$second = Message::create([
'conversation_id' => $conv->id,
'sender_id' => $userB->id,
'body' => 'Second',
]);
$third = Message::create([
'conversation_id' => $conv->id,
'sender_id' => $userA->id,
'body' => 'Third',
]);
$response = $this->actingAs($userA)->getJson("/api/messages/{$conv->id}/delta?after_message_id={$first->id}");
$response->assertStatus(200)
->assertJsonPath('data.0.id', $second->id)
->assertJsonPath('data.1.id', $third->id);
});
test('presence heartbeat marks user online and viewing a conversation', function () {
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
$this->actingAs($userA)
->postJson('/api/messages/presence/heartbeat', ['conversation_id' => $conv->id])
->assertStatus(200)
->assertJsonFragment(['conversation_id' => $conv->id]);
$presence = app(MessagingPresenceService::class);
expect($presence->isUserOnline($userA->id))->toBeTrue()
->and($presence->isViewingConversation($conv->id, $userA->id))->toBeTrue();
});
test('offline fallback notifications are skipped for online recipients', function () {
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
app(MessagingPresenceService::class)->touch($userB);
$this->actingAs($userA)->postJson("/api/messages/{$conv->id}", [
'body' => 'Presence-aware hello',
])->assertStatus(201);
expect(DB::table('notifications')->count())->toBe(0);
});
test('offline fallback notifications are stored for offline recipients', function () {
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
$this->actingAs($userA)->postJson("/api/messages/{$conv->id}", [
'body' => 'Offline hello',
])->assertStatus(201);
$notification = DB::table('notifications')->first();
expect($notification)->not->toBeNull()
->and((int) $notification->user_id)->toBe($userB->id)
->and((string) $notification->type)->toBe('message');
});
test('report endpoint creates moderation report entry', function () { test('report endpoint creates moderation report entry', function () {
$userA = makeMessagingUser(); $userA = makeMessagingUser();
$userB = makeMessagingUser(); $userB = makeMessagingUser();

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use function Pest\Laravel\artisan;
uses(RefreshDatabase::class);
beforeEach(function (): void {
config()->set('vision.vector_gateway.enabled', true);
config()->set('vision.vector_gateway.base_url', 'https://vision.klevze.net');
config()->set('vision.vector_gateway.api_key', 'test-key');
config()->set('vision.vector_gateway.upsert_endpoint', '/vectors/upsert');
config()->set('vision.vector_gateway.search_endpoint', '/vectors/search');
config()->set('vision.image_variant', 'md');
config()->set('cdn.files_url', 'https://files.skinbase.org');
});
it('indexes artworks into the vector gateway with artwork metadata', function (): void {
$contentType = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
'description' => '',
]);
$category = Category::query()->create([
'content_type_id' => $contentType->id,
'parent_id' => null,
'name' => 'Abstract',
'slug' => 'abstract',
'description' => '',
'is_active' => true,
'sort_order' => 0,
]);
$artwork = Artwork::factory()->create([
'hash' => 'aabbcc112233',
'thumb_ext' => 'webp',
]);
$artwork->categories()->attach($category->id);
Http::fake([
'https://vision.klevze.net/vectors/upsert' => Http::response(['ok' => true], 200),
]);
artisan('artworks:vectors-index', ['--limit' => 1])
->assertSuccessful();
Http::assertSent(function ($request) use ($artwork): bool {
if ($request->url() !== 'https://vision.klevze.net/vectors/upsert') {
return false;
}
$payload = json_decode($request->body(), true);
return $request->hasHeader('X-API-Key', 'test-key')
&& is_array($payload)
&& ($payload['id'] ?? null) === (string) $artwork->id
&& ($payload['url'] ?? null) === 'https://files.skinbase.org/md/aa/bb/aabbcc112233.webp'
&& ($payload['metadata']['content_type'] ?? null) === 'Photography'
&& ($payload['metadata']['category'] ?? null) === 'Abstract';
});
});
it('searches similar artworks through the vector gateway', function (): void {
$contentType = ContentType::query()->create([
'name' => 'Wallpapers',
'slug' => 'wallpapers',
'description' => '',
]);
$category = Category::query()->create([
'content_type_id' => $contentType->id,
'parent_id' => null,
'name' => 'Nature',
'slug' => 'nature',
'description' => '',
'is_active' => true,
'sort_order' => 0,
]);
$source = Artwork::factory()->create([
'title' => 'Source artwork',
'hash' => 'aabbcc112233',
'thumb_ext' => 'webp',
]);
$source->categories()->attach($category->id);
$similar = Artwork::factory()->create([
'title' => 'Nearby artwork',
'hash' => 'ddeeff445566',
'thumb_ext' => 'webp',
]);
$similar->categories()->attach($category->id);
Http::fake([
'https://vision.klevze.net/vectors/search' => Http::response([
'results' => [
['id' => $source->id, 'score' => 1.0],
['id' => $similar->id, 'score' => 0.9876],
],
], 200),
]);
artisan('artworks:vectors-search', [
'artwork_id' => $source->id,
'--limit' => 5,
])
->expectsTable(['ID', 'Score', 'Title', 'Content Type', 'Category'], [[
'id' => $similar->id,
'score' => '0.9876',
'title' => 'Nearby artwork',
'content_type' => 'Wallpapers',
'category' => 'Nature',
]])
->assertSuccessful();
});

View File

@@ -1,4 +0,0 @@
{
"status": "passed",
"failedTests": []
}