Repair: copy legacy joinDate into new user's created_at when creating users from legacy wallz
This commit is contained in:
20
.env.example
20
.env.example
@@ -47,7 +47,17 @@ QUEUE_CONNECTION=redis
|
||||
|
||||
MESSAGING_REALTIME=true
|
||||
MESSAGING_BROADCAST_QUEUE=broadcasts
|
||||
MESSAGING_TYPING_TTL=8
|
||||
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_KEY=skinbase-local-key
|
||||
@@ -77,6 +87,14 @@ SKINBASE_DUPLICATE_HASH_POLICY=block
|
||||
VISION_ENABLED=true
|
||||
VISION_QUEUE=default
|
||||
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_BASE_URL=
|
||||
@@ -101,6 +119,8 @@ RECOMMENDATIONS_AB_ALGO_VERSIONS=clip-cosine-v1
|
||||
RECOMMENDATIONS_MIN_DIM=64
|
||||
RECOMMENDATIONS_MAX_DIM=4096
|
||||
RECOMMENDATIONS_BACKFILL_BATCH=200
|
||||
SIMILARITY_VECTOR_ENABLED=false
|
||||
SIMILARITY_VECTOR_ADAPTER=pgvector
|
||||
|
||||
# Personalized discovery foundation (Phase 8)
|
||||
DISCOVERY_QUEUE=${RECOMMENDATIONS_QUEUE}
|
||||
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Support\Str;
|
||||
|
||||
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 string $migrationLogPath;
|
||||
@@ -20,7 +20,7 @@ class ImportLegacyUsers extends Command
|
||||
|
||||
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);
|
||||
|
||||
// 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;
|
||||
|
||||
// Use legacy username as-is (sanitized only, no numeric suffixing — was unique in old DB).
|
||||
$username = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId)));
|
||||
// Use legacy username as-is by default. Placeholder tmp usernames can be
|
||||
// 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 ?? ''));
|
||||
if ($normalizedLegacy !== $username) {
|
||||
@@ -173,7 +179,12 @@ class ImportLegacyUsers extends Command
|
||||
|
||||
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
|
||||
$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
|
||||
$sharedFields = [
|
||||
@@ -212,7 +223,7 @@ class ImportLegacyUsers extends Command
|
||||
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
|
||||
'language' => $row->lang ?: null,
|
||||
'birthdate' => $row->birth ?: null,
|
||||
'gender' => $row->gender ?: 'X',
|
||||
'gender' => $this->normalizeLegacyGender($row->gender ?? null),
|
||||
'website' => $row->web ?: null,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
@@ -232,7 +243,7 @@ class ImportLegacyUsers extends Command
|
||||
);
|
||||
|
||||
if (Schema::hasTable('username_redirects')) {
|
||||
$old = UsernamePolicy::normalize((string) ($row->uname ?? ''));
|
||||
$old = $this->usernameRedirectKey((string) ($row->uname ?? ''));
|
||||
if ($old !== '' && $old !== $username) {
|
||||
DB::table('username_redirects')->updateOrInsert(
|
||||
['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.
|
||||
*/
|
||||
@@ -265,6 +316,24 @@ class ImportLegacyUsers extends Command
|
||||
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
|
||||
{
|
||||
$local = strtolower(trim($value));
|
||||
|
||||
184
app/Console/Commands/IndexArtworkVectorsCommand.php
Normal file
184
app/Console/Commands/IndexArtworkVectorsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
301
app/Console/Commands/RepairLegacyWallzUsersCommand.php
Normal file
301
app/Console/Commands/RepairLegacyWallzUsersCommand.php
Normal 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';
|
||||
}
|
||||
}
|
||||
135
app/Console/Commands/RepairTemporaryUsernamesCommand.php
Normal file
135
app/Console/Commands/RepairTemporaryUsernamesCommand.php
Normal 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();
|
||||
}
|
||||
}
|
||||
118
app/Console/Commands/SearchArtworkVectorsCommand.php
Normal file
118
app/Console/Commands/SearchArtworkVectorsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ use App\Console\Commands\ImportLegacyUsers;
|
||||
use App\Console\Commands\ImportCategories;
|
||||
use App\Console\Commands\MigrateFeaturedWorks;
|
||||
use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
|
||||
use App\Console\Commands\IndexArtworkVectorsCommand;
|
||||
use App\Console\Commands\SearchArtworkVectorsCommand;
|
||||
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
|
||||
use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
||||
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
|
||||
@@ -43,6 +45,8 @@ class Kernel extends ConsoleKernel
|
||||
CleanupUploadsCommand::class,
|
||||
PublishScheduledArtworksCommand::class,
|
||||
BackfillArtworkEmbeddingsCommand::class,
|
||||
IndexArtworkVectorsCommand::class,
|
||||
SearchArtworkVectorsCommand::class,
|
||||
AggregateSimilarArtworkAnalyticsCommand::class,
|
||||
AggregateFeedAnalyticsCommand::class,
|
||||
AggregateTagInteractionAnalyticsCommand::class,
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Events;
|
||||
|
||||
use App\Models\Conversation;
|
||||
use App\Services\Messaging\MessagingPayloadFactory;
|
||||
use App\Services\Messaging\UnreadCounterService;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
@@ -41,6 +42,9 @@ class ConversationUpdated implements ShouldBroadcast
|
||||
'event' => 'conversation.updated',
|
||||
'reason' => $this->reason,
|
||||
'conversation' => app(MessagingPayloadFactory::class)->conversationSummary($this->conversation, $this->userId),
|
||||
'summary' => [
|
||||
'unread_total' => app(UnreadCounterService::class)->totalUnreadForUser($this->userId),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,7 @@ class ArtworkCommentController extends Controller
|
||||
'id' => $c->id,
|
||||
'parent_id' => $c->parent_id,
|
||||
'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(),
|
||||
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
||||
'can_edit' => $currentUserId === $userId,
|
||||
@@ -224,6 +224,31 @@ class ArtworkCommentController extends Controller
|
||||
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
|
||||
{
|
||||
$notifiedUserIds = [];
|
||||
|
||||
@@ -9,10 +9,11 @@ use App\Http\Requests\Messaging\RenameConversationRequest;
|
||||
use App\Http\Requests\Messaging\StoreConversationRequest;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use App\Services\Messaging\ConversationReadService;
|
||||
use App\Services\Messaging\ConversationStateService;
|
||||
use App\Services\Messaging\SendMessageAction;
|
||||
use App\Services\Messaging\UnreadCounterService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -23,7 +24,9 @@ class ConversationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ConversationStateService $conversationState,
|
||||
private readonly ConversationReadService $conversationReads,
|
||||
private readonly SendMessageAction $sendMessage,
|
||||
private readonly UnreadCounterService $unreadCounters,
|
||||
) {}
|
||||
|
||||
// ── GET /api/messages/conversations ─────────────────────────────────────
|
||||
@@ -36,26 +39,13 @@ class ConversationController extends Controller
|
||||
$cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion);
|
||||
|
||||
$conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) {
|
||||
return Conversation::query()
|
||||
$query = Conversation::query()
|
||||
->select('conversations.*')
|
||||
->join('conversation_participants as cp_me', function ($join) use ($user) {
|
||||
$join->on('cp_me.conversation_id', '=', 'conversations.id')
|
||||
->where('cp_me.user_id', '=', $user->id)
|
||||
->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)
|
||||
->with([
|
||||
'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.pinned_at')
|
||||
->orderByDesc('last_message_at')
|
||||
->orderByDesc('conversations.id')
|
||||
->paginate(20, ['conversations.*'], 'page', $page);
|
||||
->orderByDesc('conversations.id');
|
||||
|
||||
$this->unreadCounters->applyUnreadCountSelect($query, $user, 'cp_me');
|
||||
|
||||
return $query->paginate(20, ['conversations.*'], 'page', $page);
|
||||
});
|
||||
|
||||
$conversations->through(function ($conv) use ($user) {
|
||||
@@ -74,7 +67,12 @@ class ConversationController extends Controller
|
||||
return $conv;
|
||||
});
|
||||
|
||||
return response()->json($conversations);
|
||||
return response()->json([
|
||||
...$conversations->toArray(),
|
||||
'summary' => [
|
||||
'unread_total' => $this->unreadCounters->totalUnreadForUser($user),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── GET /api/messages/conversation/{id} ─────────────────────────────────
|
||||
@@ -110,7 +108,7 @@ class ConversationController extends Controller
|
||||
public function markRead(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conversation = $this->findAuthorized($request, $id);
|
||||
$participant = $this->conversationState->markConversationRead(
|
||||
$participant = $this->conversationReads->markConversationRead(
|
||||
$conversation,
|
||||
$request->user(),
|
||||
$request->integer('message_id') ?: null,
|
||||
@@ -120,6 +118,7 @@ class ConversationController extends Controller
|
||||
'ok' => true,
|
||||
'last_read_at' => optional($participant->last_read_at)?->toIso8601String(),
|
||||
'last_read_message_id' => $participant->last_read_message_id,
|
||||
'unread_total' => $this->unreadCounters->totalUnreadForUser($request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\MessageReaction;
|
||||
use App\Services\Messaging\ConversationDeltaService;
|
||||
use App\Services\Messaging\ConversationStateService;
|
||||
use App\Services\Messaging\MessagingPayloadFactory;
|
||||
use App\Services\Messaging\MessageSearchIndexer;
|
||||
@@ -26,6 +27,7 @@ class MessageController extends Controller
|
||||
private const PAGE_SIZE = 30;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConversationDeltaService $conversationDelta,
|
||||
private readonly ConversationStateService $conversationState,
|
||||
private readonly MessagingPayloadFactory $payloadFactory,
|
||||
private readonly SendMessageAction $sendMessage,
|
||||
@@ -40,15 +42,7 @@ class MessageController extends Controller
|
||||
$afterId = $request->integer('after_id');
|
||||
|
||||
if ($afterId) {
|
||||
$messages = Message::withTrashed()
|
||||
->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();
|
||||
$messages = $this->conversationDelta->messagesAfter($conversation, $request->user(), $afterId);
|
||||
|
||||
return response()->json([
|
||||
'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} ─────────────────────────────────
|
||||
|
||||
public function store(StoreMessageRequest $request, int $conversationId): JsonResponse
|
||||
|
||||
33
app/Http/Controllers/Api/Messaging/PresenceController.php
Normal file
33
app/Http/Controllers/Api/Messaging/PresenceController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,14 @@ final class ProfileApiController extends Controller
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
$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)
|
||||
->whereNull('deleted_at');
|
||||
|
||||
@@ -106,7 +113,14 @@ final class ProfileApiController extends Controller
|
||||
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)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
@@ -173,6 +187,9 @@ final class ProfileApiController extends Controller
|
||||
private function mapArtworkCardPayload(Artwork $art): array
|
||||
{
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
$category = $art->categories->first();
|
||||
$contentType = $category?->contentType;
|
||||
$stats = $art->stats;
|
||||
|
||||
return [
|
||||
'id' => $art->id,
|
||||
@@ -183,6 +200,13 @@ final class ProfileApiController extends Controller
|
||||
'height' => $art->height,
|
||||
'username' => $art->user->username ?? null,
|
||||
'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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -49,6 +49,18 @@ use Inertia\Inertia;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
private const PROFILE_TABS = [
|
||||
'posts',
|
||||
'artworks',
|
||||
'stories',
|
||||
'achievements',
|
||||
'collections',
|
||||
'about',
|
||||
'stats',
|
||||
'favourites',
|
||||
'activity',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ArtworkService $artworkService,
|
||||
private readonly UsernameApprovalService $usernameApprovalService,
|
||||
@@ -84,7 +96,12 @@ class ProfileController extends Controller
|
||||
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)
|
||||
@@ -111,6 +128,45 @@ class ProfileController extends Controller
|
||||
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)
|
||||
{
|
||||
$user = User::query()->findOrFail($id);
|
||||
@@ -836,7 +892,13 @@ class ProfileController extends Controller
|
||||
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;
|
||||
$viewer = Auth::user();
|
||||
@@ -1088,8 +1150,19 @@ class ProfileController extends Controller
|
||||
$usernameSlug = strtolower((string) ($user->username ?? ''));
|
||||
$canonical = url('/@' . $usernameSlug);
|
||||
$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);
|
||||
$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, [
|
||||
'user' => [
|
||||
@@ -1133,20 +1206,51 @@ class ProfileController extends Controller
|
||||
'countryName' => $countryName,
|
||||
'isOwner' => $isOwner,
|
||||
'auth' => $authData,
|
||||
'initialTab' => $resolvedInitialTab,
|
||||
'profileUrl' => $canonical,
|
||||
'galleryUrl' => $galleryUrl,
|
||||
'profileTabUrls' => $profileTabUrls,
|
||||
])->withViewData([
|
||||
'page_title' => $galleryOnly
|
||||
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
|
||||
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase'),
|
||||
'page_canonical' => $galleryOnly ? $galleryUrl : $canonical,
|
||||
: ($isTabLanding
|
||||
? (($user->username ?? $user->name ?? 'User') . ' ' . $tabMetaLabel . ' on Skinbase')
|
||||
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase')),
|
||||
'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl,
|
||||
'page_meta_description' => $galleryOnly
|
||||
? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.')
|
||||
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.'),
|
||||
: ($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,
|
||||
]);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) {
|
||||
@@ -1164,6 +1268,9 @@ class ProfileController extends Controller
|
||||
private function mapArtworkCardPayload(Artwork $art): array
|
||||
{
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
$category = $art->categories->first();
|
||||
$contentType = $category?->contentType;
|
||||
$stats = $art->stats;
|
||||
|
||||
return [
|
||||
'id' => $art->id,
|
||||
@@ -1178,6 +1285,13 @@ class ProfileController extends Controller
|
||||
'user_id' => $art->user_id,
|
||||
'author_level' => (int) ($art->user?->level ?? 1),
|
||||
'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,
|
||||
'height' => $art->height,
|
||||
];
|
||||
|
||||
@@ -8,8 +8,11 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\ErrorSuggestionService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
@@ -167,23 +170,38 @@ final class ArtworkPageController extends Controller
|
||||
|
||||
// Recursive helper to format a comment and its nested replies
|
||||
$formatComment = null;
|
||||
$formatComment = function(ArtworkComment $c) use (&$formatComment) {
|
||||
$formatComment = function (ArtworkComment $c) use (&$formatComment): array {
|
||||
$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 [
|
||||
'id' => $c->id,
|
||||
'parent_id' => $c->parent_id,
|
||||
'content' => html_entity_decode((string) $c->content, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'raw_content' => $c->raw_content ?? $c->content,
|
||||
'rendered_content' => $c->rendered_content,
|
||||
'created_at' => $c->created_at?->toIsoString(),
|
||||
'rendered_content' => ContentSanitizer::sanitizeRenderedHtml($renderedContent, $canPublishLinks),
|
||||
'created_at' => $c->created_at?->toIso8601String(),
|
||||
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
||||
'user' => [
|
||||
'id' => $c->user?->id,
|
||||
'name' => $c->user?->name,
|
||||
'username' => $c->user?->username,
|
||||
'display' => $c->user?->username ?? $c->user?->name ?? 'User',
|
||||
'profile_url' => $c->user?->username ? '/@' . $c->user->username : null,
|
||||
'avatar_url' => $c->user?->profile?->avatar_url,
|
||||
'id' => $userId,
|
||||
'name' => $user?->name,
|
||||
'username' => $user?->username,
|
||||
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||
'profile_url' => $user?->username ? '/@' . $user->username : ($userId > 0 ? '/profile/' . $userId : null),
|
||||
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
||||
'level' => (int) ($user?->level ?? 1),
|
||||
'rank' => (string) ($user?->rank ?? 'Newbie'),
|
||||
],
|
||||
'replies' => $replies->map($formatComment)->values()->all(),
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -100,6 +101,7 @@ class ArtworkResource extends JsonResource
|
||||
'slug' => (string) $this->slug,
|
||||
'title' => $decode($this->title),
|
||||
'description' => $decode($this->description),
|
||||
'description_html' => $this->renderDescriptionHtml(),
|
||||
'dimensions' => [
|
||||
'width' => (int) ($this->width ?? 0),
|
||||
'height' => (int) ($this->height ?? 0),
|
||||
@@ -123,6 +125,8 @@ class ArtworkResource extends JsonResource
|
||||
'username' => (string) ($this->user?->username ?? ''),
|
||||
'profile_url' => $this->user?->username ? '/@' . $this->user->username : null,
|
||||
'avatar_url' => $this->user?->profile?->avatar_url,
|
||||
'level' => (int) ($this->user?->level ?? 1),
|
||||
'rank' => (string) ($this->user?->rank ?? 'Newbie'),
|
||||
'followers_count' => $followerCount,
|
||||
],
|
||||
'viewer' => [
|
||||
@@ -168,4 +172,27 @@ class ArtworkResource extends JsonResource
|
||||
])->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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Jobs;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkEmbedding;
|
||||
use App\Services\Vision\ArtworkEmbeddingClient;
|
||||
use App\Services\Vision\ArtworkVisionImageUrl;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -41,7 +42,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
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)) {
|
||||
return;
|
||||
@@ -79,7 +80,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
}
|
||||
|
||||
try {
|
||||
$imageUrl = $this->buildImageUrl($sourceHash);
|
||||
$imageUrl = $imageUrlBuilder->fromHash($sourceHash, (string) ($artwork->thumb_ext ?: 'webp'));
|
||||
if ($imageUrl === null) {
|
||||
return;
|
||||
}
|
||||
@@ -134,21 +135,6 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
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
|
||||
{
|
||||
return 'artwork-embedding:lock:' . $artworkId . ':' . $model . ':' . $version;
|
||||
|
||||
@@ -40,7 +40,7 @@ class ArtworkSharedNotification extends Notification implements ShouldQueue
|
||||
'sharer_name' => $this->sharer->name,
|
||||
'sharer_username' => $this->sharer->username,
|
||||
'message' => $this->sharer->name . ' shared your artwork "' . $this->artwork->title . '"',
|
||||
'url' => "/@{$this->sharer->username}?tab=posts",
|
||||
'url' => "/@{$this->sharer->username}/posts",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class PostCommentedNotification extends Notification implements ShouldQueue
|
||||
'commenter_name' => $this->commenter->name,
|
||||
'commenter_username' => $this->commenter->username,
|
||||
'message' => "{$this->commenter->name} commented on your post",
|
||||
'url' => "/@{$this->post->user->username}?tab=posts",
|
||||
'url' => "/@{$this->post->user->username}/posts",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +290,44 @@ class AppServiceProvider extends ServiceProvider
|
||||
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
|
||||
|
||||
34
app/Providers/HorizonServiceProvider.php
Normal file
34
app/Providers/HorizonServiceProvider.php
Normal 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());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -301,7 +301,8 @@ class ArtworkService
|
||||
{
|
||||
$query = Artwork::where('user_id', $userId)
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user:id,name,username,level,rank',
|
||||
'stats:artwork_id,views,downloads,favorites',
|
||||
'categories' => function ($q) {
|
||||
$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']);
|
||||
|
||||
@@ -72,6 +72,20 @@ class ContentSanitizer
|
||||
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.
|
||||
*/
|
||||
@@ -190,7 +204,7 @@ class ContentSanitizer
|
||||
* Whitelist-based HTML sanitizer.
|
||||
* 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
|
||||
$doc = new \DOMDocument('1.0', 'UTF-8');
|
||||
@@ -202,7 +216,7 @@ class ContentSanitizer
|
||||
);
|
||||
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
|
||||
$body = $doc->getElementsByTagName('body')->item(0);
|
||||
@@ -218,13 +232,17 @@ class ContentSanitizer
|
||||
/**
|
||||
* 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 = [];
|
||||
$toUnwrap = [];
|
||||
|
||||
foreach ($node->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
if (! $child instanceof \DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tag = strtolower($child->nodeName);
|
||||
|
||||
if (! in_array($tag, self::ALLOWED_TAGS, true)) {
|
||||
@@ -245,17 +263,22 @@ class ContentSanitizer
|
||||
|
||||
// Force external links to be safe
|
||||
if ($tag === 'a') {
|
||||
if (! $allowLinks) {
|
||||
$toUnwrap[] = $child;
|
||||
continue;
|
||||
}
|
||||
|
||||
$href = $child->getAttribute('href');
|
||||
if ($href && ! static::isSafeUrl($href)) {
|
||||
$toUnwrap[] = $child;
|
||||
continue;
|
||||
}
|
||||
$child->setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
$child->setAttribute('rel', 'noopener noreferrer nofollow ugc');
|
||||
$child->setAttribute('target', '_blank');
|
||||
}
|
||||
|
||||
// Recurse
|
||||
static::cleanNode($child);
|
||||
static::cleanNode($child, $allowLinks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
app/Services/Messaging/ConversationDeltaService.php
Normal file
31
app/Services/Messaging/ConversationDeltaService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
76
app/Services/Messaging/ConversationReadService.php
Normal file
76
app/Services/Messaging/ConversationReadService.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,9 @@
|
||||
|
||||
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\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ConversationStateService
|
||||
{
|
||||
@@ -37,62 +32,4 @@ class ConversationStateService
|
||||
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']);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,10 @@ use Illuminate\Support\Str;
|
||||
|
||||
class MessageNotificationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessagingPresenceService $presence,
|
||||
) {}
|
||||
|
||||
public function notifyNewMessage(Conversation $conversation, Message $message, User $sender): void
|
||||
{
|
||||
if (! DB::getSchemaBuilder()->hasTable('notifications')) {
|
||||
@@ -36,6 +40,13 @@ class MessageNotificationService
|
||||
->whereIn('id', $recipientIds)
|
||||
->get()
|
||||
->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')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->values()
|
||||
|
||||
@@ -56,7 +56,7 @@ class MessagingPayloadFactory
|
||||
'title' => $conversation->title,
|
||||
'is_active' => (bool) ($conversation->is_active ?? true),
|
||||
'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,
|
||||
'all_participants' => $conversation->allParticipants
|
||||
->whereNull('left_at')
|
||||
|
||||
69
app/Services/Messaging/MessagingPresenceService.php
Normal file
69
app/Services/Messaging/MessagingPresenceService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
81
app/Services/Messaging/UnreadCounterService.php
Normal file
81
app/Services/Messaging/UnreadCounterService.php
Normal 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));
|
||||
}
|
||||
}
|
||||
29
app/Services/Vision/ArtworkVisionImageUrl.php
Normal file
29
app/Services/Vision/ArtworkVisionImageUrl.php
Normal 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);
|
||||
}
|
||||
}
|
||||
213
app/Services/Vision/VectorGatewayClient.php
Normal file
213
app/Services/Vision/VectorGatewayClient.php
Normal 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 : [];
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,10 @@
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\AuthServiceProvider::class,
|
||||
App\Providers\HorizonServiceProvider::class,
|
||||
Klevze\ControlPanel\ServiceProvider::class,
|
||||
cPad\Plugins\Artworks\ServiceProvider::class,
|
||||
cPad\Plugins\News\ServiceProvider::class,
|
||||
cPad\Plugins\Forum\ServiceProvider::class,
|
||||
cPad\Plugins\News\ServiceProvider::class,
|
||||
cPad\Plugins\Site\ServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"intervention/image": "^3.11",
|
||||
"jenssegers/agent": "*",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/horizon": "^5.45",
|
||||
"laravel/reverb": "^1.0",
|
||||
"laravel/scout": "^10.24",
|
||||
"laravel/socialite": "^5.24",
|
||||
|
||||
141
composer.lock
generated
141
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "e1ededa537b256c2936370d7e28a4bd5",
|
||||
"content-hash": "7310d1d07635e290193ccbe4539b1397",
|
||||
"packages": [
|
||||
{
|
||||
"name": "alexusmai/laravel-file-manager",
|
||||
@@ -2209,6 +2209,86 @@
|
||||
},
|
||||
"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",
|
||||
"version": "v0.3.13",
|
||||
@@ -2427,6 +2507,65 @@
|
||||
},
|
||||
"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",
|
||||
"version": "v2.0.10",
|
||||
|
||||
277
config/horizon.php
Normal file
277
config/horizon.php
Normal 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',
|
||||
],
|
||||
];
|
||||
@@ -12,6 +12,20 @@ return [
|
||||
'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' => [
|
||||
'index' => env('MESSAGING_MEILI_INDEX', 'messages'),
|
||||
'page_size' => (int) env('MESSAGING_SEARCH_PAGE_SIZE', 20),
|
||||
|
||||
@@ -44,6 +44,21 @@ return [
|
||||
'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)
|
||||
|
||||
@@ -1,26 +1,44 @@
|
||||
# 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
|
||||
|
||||
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`.
|
||||
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`.
|
||||
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`.
|
||||
|
||||
## 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
|
||||
|
||||
- Use `BROADCAST_CONNECTION=reverb` and `QUEUE_CONNECTION=redis`.
|
||||
- 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.
|
||||
- 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.
|
||||
- Database notification fallback now only runs for recipients who are not marked online in messaging presence.
|
||||
|
||||
## Reconnect model
|
||||
|
||||
- The conversation view loads once via HTTP.
|
||||
- Live message, read, and typing updates arrive over websocket channels.
|
||||
- When the socket reconnects, the client requests message deltas with `after_id` to merge missed messages idempotently.
|
||||
- Live message, read, typing, and conversation summary updates arrive over websocket channels.
|
||||
- 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
@@ -61,10 +61,12 @@ function buildSearchPreview(item) {
|
||||
|
||||
function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
const [conversations, setConversations] = useState([])
|
||||
const [unreadTotal, setUnreadTotal] = useState(null)
|
||||
const [loadingConvs, setLoadingConvs] = useState(true)
|
||||
const [activeId, setActiveId] = useState(initialId ?? null)
|
||||
const [realtimeEnabled, setRealtimeEnabled] = useState(false)
|
||||
const [realtimeStatus, setRealtimeStatus] = useState('offline')
|
||||
const [onlineUserIds, setOnlineUserIds] = useState([])
|
||||
const [typingByConversation, setTypingByConversation] = useState({})
|
||||
const [showNewModal, setShowNewModal] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
@@ -75,6 +77,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
try {
|
||||
const data = await apiFetch('/api/messages/conversations')
|
||||
setConversations(data.data ?? [])
|
||||
setUnreadTotal(Number.isFinite(Number(data?.summary?.unread_total)) ? Number(data.summary.unread_total) : null)
|
||||
} catch (e) {
|
||||
console.error('Failed to load conversations', e)
|
||||
} finally {
|
||||
@@ -173,6 +176,11 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
}
|
||||
|
||||
setConversations((prev) => mergeConversationSummary(prev, nextConversation))
|
||||
|
||||
const nextUnreadTotal = Number(payload?.summary?.unread_total)
|
||||
if (Number.isFinite(nextUnreadTotal)) {
|
||||
setUnreadTotal(nextUnreadTotal)
|
||||
}
|
||||
}
|
||||
|
||||
channel.listen('.conversation.updated', handleConversationUpdated)
|
||||
@@ -192,6 +200,79 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (!realtimeEnabled) {
|
||||
setTypingByConversation({})
|
||||
@@ -310,12 +391,16 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
history.replaceState(null, '', `/messages/${conv.id}`)
|
||||
}, [loadConversations])
|
||||
|
||||
const handleMarkRead = useCallback((conversationId) => {
|
||||
const handleMarkRead = useCallback((conversationId, nextUnreadTotal = null) => {
|
||||
setConversations((prev) => prev.map((conversation) => (
|
||||
conversation.id === conversationId
|
||||
? { ...conversation, unread_count: 0 }
|
||||
: conversation
|
||||
)))
|
||||
|
||||
if (Number.isFinite(Number(nextUnreadTotal))) {
|
||||
setUnreadTotal(Number(nextUnreadTotal))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConversationPatched = useCallback((patch) => {
|
||||
@@ -369,7 +454,9 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
}, [])
|
||||
|
||||
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 me = conversation.my_participant ?? conversation.all_participants?.find((participant) => participant.user_id === userId)
|
||||
return sum + (me?.is_pinned ? 1 : 0)
|
||||
@@ -475,6 +562,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
loading={loadingConvs}
|
||||
activeId={activeId}
|
||||
currentUserId={userId}
|
||||
onlineUserIds={onlineUserIds}
|
||||
typingByConversation={typingByConversation}
|
||||
onSelect={handleSelectConversation}
|
||||
/>
|
||||
@@ -490,6 +578,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
realtimeStatus={realtimeStatus}
|
||||
currentUserId={userId}
|
||||
currentUsername={username}
|
||||
onlineUserIds={onlineUserIds}
|
||||
apiFetch={apiFetch}
|
||||
onBack={() => {
|
||||
setActiveId(null)
|
||||
|
||||
@@ -66,10 +66,9 @@ export default function ProfileGallery() {
|
||||
</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
|
||||
artworks={artworks}
|
||||
featuredArtworks={featuredArtworks}
|
||||
username={username}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import ProfileHero from '../../components/profile/ProfileHero'
|
||||
import ProfileStatsRow from '../../components/profile/ProfileStatsRow'
|
||||
import ProfileTabs from '../../components/profile/ProfileTabs'
|
||||
import TabArtworks from '../../components/profile/tabs/TabArtworks'
|
||||
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 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() {
|
||||
try {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const t = sp.get('tab')
|
||||
return VALID_TABS.includes(t) ? t : 'artworks'
|
||||
} catch {
|
||||
return 'artworks'
|
||||
function getInitialTab(initialTab = 'posts') {
|
||||
if (typeof window === 'undefined') {
|
||||
return VALID_TABS.includes(initialTab) ? initialTab : 'posts'
|
||||
}
|
||||
|
||||
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,
|
||||
isOwner,
|
||||
auth,
|
||||
initialTab,
|
||||
profileUrl,
|
||||
galleryUrl,
|
||||
profileTabUrls,
|
||||
} = props
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab)
|
||||
const [activeTab, setActiveTab] = useState(() => getInitialTab(initialTab))
|
||||
|
||||
const handleTabChange = useCallback((tab) => {
|
||||
if (!VALID_TABS.includes(tab)) return
|
||||
setActiveTab(tab)
|
||||
|
||||
// Update URL query param without full navigation
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
if (tab === 'artworks') {
|
||||
url.searchParams.delete('tab')
|
||||
} else {
|
||||
url.searchParams.set('tab', tab)
|
||||
}
|
||||
window.history.pushState({}, '', url.toString())
|
||||
} catch (_) {}
|
||||
}, [])
|
||||
const currentUrl = new URL(window.location.href)
|
||||
const targetBase = profileTabUrls?.[tab] || `${profileUrl || `${window.location.origin}`}/${tab}`
|
||||
const nextUrl = new URL(targetBase, window.location.origin)
|
||||
const sharedPostId = currentUrl.searchParams.get('post')
|
||||
|
||||
if (sharedPostId) {
|
||||
nextUrl.searchParams.set('post', sharedPostId)
|
||||
}
|
||||
|
||||
window.history.pushState({}, '', nextUrl.toString())
|
||||
} catch (_) {}
|
||||
}, [profileTabUrls, profileUrl])
|
||||
|
||||
// Handle browser back/forward
|
||||
useEffect(() => {
|
||||
const onPop = () => setActiveTab(getInitialTab())
|
||||
const onPop = () => setActiveTab(getInitialTab(initialTab))
|
||||
window.addEventListener('popstate', onPop)
|
||||
return () => window.removeEventListener('popstate', onPop)
|
||||
}, [])
|
||||
}, [initialTab])
|
||||
|
||||
const isLoggedIn = !!(auth?.user)
|
||||
|
||||
@@ -98,9 +110,27 @@ export default function ProfileShow() {
|
||||
? socialLinks.reduce((acc, l) => { acc[l.platform] = l; return acc }, {})
|
||||
: (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 (
|
||||
<div className="min-h-screen pb-16">
|
||||
{/* Hero section */}
|
||||
<div className="relative min-h-screen overflow-hidden pb-16">
|
||||
<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
|
||||
user={user}
|
||||
profile={profile}
|
||||
@@ -121,26 +151,20 @@ export default function ProfileShow() {
|
||||
) : null}
|
||||
/>
|
||||
|
||||
{/* Stats pills row */}
|
||||
<ProfileStatsRow
|
||||
stats={stats}
|
||||
followerCount={followerCount}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
<div className="mt-6">
|
||||
<ProfileTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sticky tabs */}
|
||||
<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'}>
|
||||
<div className={`${contentShellClassName} pt-6`}>
|
||||
{activeTab === 'artworks' && (
|
||||
<TabArtworks
|
||||
artworks={{ data: artworkList, next_cursor: artworkNextCursor }}
|
||||
featuredArtworks={featuredArtworks}
|
||||
username={user.username || user.name}
|
||||
galleryUrl={galleryUrl}
|
||||
isActive
|
||||
/>
|
||||
)}
|
||||
@@ -156,6 +180,7 @@ export default function ProfileShow() {
|
||||
recentFollowers={recentFollowers}
|
||||
socialLinks={socialLinksObj}
|
||||
countryName={countryName}
|
||||
profileUrl={profileUrl}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
)}
|
||||
@@ -175,9 +200,16 @@ export default function ProfileShow() {
|
||||
<TabAbout
|
||||
user={user}
|
||||
profile={profile}
|
||||
stats={stats}
|
||||
achievements={achievements}
|
||||
artworks={artworkList}
|
||||
creatorStories={creatorStories}
|
||||
profileComments={profileComments}
|
||||
socialLinks={socialLinksObj}
|
||||
countryName={countryName}
|
||||
followerCount={followerCount}
|
||||
recentFollowers={recentFollowers}
|
||||
leaderboardRank={leaderboardRank}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'stats' && (
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function PostActions({
|
||||
}
|
||||
|
||||
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)
|
||||
setShareMsg('Link copied!')
|
||||
setTimeout(() => setShareMsg(null), 2000)
|
||||
|
||||
@@ -1,67 +1,28 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
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 }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const content = (artwork?.description || '').trim()
|
||||
const contentHtml = (artwork?.description_html || '').trim()
|
||||
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
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<button
|
||||
|
||||
@@ -97,12 +97,32 @@ function slugify(text) {
|
||||
}
|
||||
|
||||
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') {
|
||||
const div = document.createElement('div')
|
||||
div.innerHTML = html
|
||||
div.innerHTML = decodeEntities(html)
|
||||
return div.textContent || div.innerText || ''
|
||||
}
|
||||
return html.replace(/<[^>]*>/g, '')
|
||||
return decodeEntities(html).replace(/<[^>]*>/g, '')
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 (
|
||||
<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">
|
||||
@@ -28,6 +28,7 @@ export default function ConversationList({ conversations, loading, activeId, cur
|
||||
conv={conversation}
|
||||
isActive={conversation.id === activeId}
|
||||
currentUserId={currentUserId}
|
||||
onlineUserIds={onlineUserIds}
|
||||
typingUsers={typingByConversation[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 lastMsg = Array.isArray(conv.latest_message) ? conv.latest_message[0] : conv.latest_message
|
||||
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'
|
||||
const unread = conv.unread_count ?? 0
|
||||
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 isPinned = myParticipant?.is_pinned ?? false
|
||||
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 initials = label
|
||||
.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' : ''}`}
|
||||
>
|
||||
<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'}`}>
|
||||
{initials}
|
||||
<div className="relative shrink-0">
|
||||
<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 className="min-w-0 flex-1">
|
||||
|
||||
@@ -9,6 +9,7 @@ export default function ConversationThread({
|
||||
realtimeStatus,
|
||||
currentUserId,
|
||||
currentUsername,
|
||||
onlineUserIds,
|
||||
apiFetch,
|
||||
onBack,
|
||||
onMarkRead,
|
||||
@@ -65,6 +66,13 @@ export default function ConversationThread({
|
||||
.map((participant) => participant.user?.username)
|
||||
.filter(Boolean)
|
||||
), [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 query = threadSearch.trim().toLowerCase()
|
||||
@@ -185,7 +193,7 @@ export default function ConversationThread({
|
||||
}
|
||||
: participant
|
||||
)))
|
||||
onMarkRead?.(conversationId)
|
||||
onMarkRead?.(conversationId, response?.unread_total ?? null)
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
@@ -309,7 +317,7 @@ export default function ConversationThread({
|
||||
}
|
||||
|
||||
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)
|
||||
if (incoming.length > 0) {
|
||||
setMessages((prev) => mergeMessageLists(prev, incoming))
|
||||
@@ -622,9 +630,11 @@ export default function ConversationThread({
|
||||
}, [apiFetch, conversation?.title, conversationId, draftTitle, patchConversation])
|
||||
|
||||
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 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
|
||||
? `${typingLabel} ${conversation?.type === 'group' ? '' : 'Reply will appear here instantly.'}`.trim()
|
||||
: null
|
||||
@@ -796,7 +806,7 @@ export default function ConversationThread({
|
||||
const showAvatar = !previous || previous.sender_id !== message.sender_id
|
||||
const endsSequence = !next || next.sender_id !== message.sender_id
|
||||
const seenText = isLastMineMessage(visibleMessages, index, currentUserId)
|
||||
? buildSeenText(participants, currentUserId)
|
||||
? buildSeenText(participants, currentUserId, message)
|
||||
: null
|
||||
|
||||
return (
|
||||
@@ -1037,29 +1047,38 @@ function isLastMineMessage(messages, index, currentUserId) {
|
||||
return true
|
||||
}
|
||||
|
||||
function buildSeenText(participants, currentUserId) {
|
||||
const seenBy = participants
|
||||
.filter((participant) => participant.user_id !== currentUserId && participant.last_read_at)
|
||||
.map((participant) => participant.user?.username)
|
||||
.filter(Boolean)
|
||||
function buildSeenText(participants, currentUserId, message) {
|
||||
const seenBy = participants.filter((participant) => participant.user_id !== currentUserId && participantHasReadMessage(participant, message))
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
function decorateMessages(messages, currentUserId, lastReadAt) {
|
||||
function decorateMessages(messages, currentUserId, participant) {
|
||||
let unreadMarked = false
|
||||
const lastReadMessageId = Number(participant?.last_read_message_id ?? 0)
|
||||
const lastReadAt = participant?.last_read_at ?? null
|
||||
|
||||
return messages.map((message, index) => {
|
||||
const previous = messages[index - 1]
|
||||
const currentDay = dayKey(message.created_at)
|
||||
const previousDay = previous ? dayKey(previous.created_at) : null
|
||||
const shouldMarkUnread = !unreadMarked
|
||||
&& !!lastReadAt
|
||||
&& message.sender_id !== currentUserId
|
||||
&& !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
|
||||
|
||||
@@ -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) {
|
||||
const date = new Date(iso)
|
||||
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
|
||||
|
||||
@@ -9,46 +9,31 @@ const SORT_OPTIONS = [
|
||||
{ value: 'favs', label: 'Most Favourited' },
|
||||
]
|
||||
|
||||
function slugify(str) {
|
||||
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function FeaturedStrip({ featuredArtworks }) {
|
||||
if (!featuredArtworks?.length) return null
|
||||
|
||||
function GalleryToolbar({ sort, onSort }) {
|
||||
return (
|
||||
<div className="mb-7 rounded-2xl border border-white/10 bg-white/[0.02] p-4 md:p-5">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-slate-400">
|
||||
<i className="fa-solid fa-star fa-fw text-amber-400" />
|
||||
Featured
|
||||
</h2>
|
||||
<div className="scrollbar-hide flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2">
|
||||
{featuredArtworks.slice(0, 5).map((art) => (
|
||||
<a
|
||||
key={art.id}
|
||||
href={`/art/${art.id}/${slugify(art.name)}`}
|
||||
className="group w-56 shrink-0 snap-start md:w-64"
|
||||
<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 rounded-2xl border border-white/10 bg-white/[0.03] p-1">
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onSort(opt.value)}
|
||||
className={`rounded-xl px-3.5 py-2 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'
|
||||
}`}
|
||||
>
|
||||
<div className="aspect-[5/3] overflow-hidden rounded-xl bg-black/30 ring-1 ring-white/10 transition-all hover:ring-sky-400/40">
|
||||
<img
|
||||
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>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProfileGalleryPanel({ artworks, featuredArtworks, username }) {
|
||||
export default function ProfileGalleryPanel({ artworks, username }) {
|
||||
const [sort, setSort] = useState('latest')
|
||||
const [items, setItems] = useState(artworks?.data ?? artworks ?? [])
|
||||
const [nextCursor, setNextCursor] = useState(artworks?.next_cursor ?? null)
|
||||
@@ -74,36 +59,20 @@ export default function ProfileGalleryPanel({ artworks, featuredArtworks, userna
|
||||
|
||||
return (
|
||||
<>
|
||||
<FeaturedStrip featuredArtworks={featuredArtworks} />
|
||||
|
||||
<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 className="mx-auto w-full max-w-6xl px-4 md:px-6">
|
||||
<GalleryToolbar sort={sort} onSort={handleSort} />
|
||||
</div>
|
||||
|
||||
<MasonryGallery
|
||||
key={`profile-${username}-${sort}`}
|
||||
artworks={items}
|
||||
galleryType="profile"
|
||||
cursorEndpoint={`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`}
|
||||
initialNextCursor={nextCursor}
|
||||
limit={24}
|
||||
/>
|
||||
<div className="w-full px-4 md:px-6 xl:px-8">
|
||||
<MasonryGallery
|
||||
key={`profile-${username}-${sort}`}
|
||||
artworks={items}
|
||||
galleryType="profile"
|
||||
cursorEndpoint={`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`}
|
||||
initialNextCursor={nextCursor}
|
||||
limit={24}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import LevelBadge from '../xp/LevelBadge'
|
||||
import XPProgressBar from '../xp/XPProgressBar'
|
||||
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 }) {
|
||||
const [following, setFollowing] = useState(viewerIsFollowing)
|
||||
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' })
|
||||
: null
|
||||
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 (
|
||||
<>
|
||||
<div className="max-w-6xl mx-auto px-4 pt-4">
|
||||
<div className="relative overflow-hidden rounded-2xl border border-white/10">
|
||||
<div className="relative mx-auto max-w-7xl px-4 pt-4 md:pt-6">
|
||||
<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
|
||||
className="w-full h-[180px] md:h-[220px] xl:h-[252px]"
|
||||
className="w-full h-[208px] md:h-[248px] xl:h-[288px]"
|
||||
style={{
|
||||
background: coverUrl
|
||||
? `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',
|
||||
}}
|
||||
>
|
||||
<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 ? (
|
||||
<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
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<i className="fa-solid fa-image" />
|
||||
@@ -49,148 +81,165 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: coverUrl
|
||||
? 'linear-gradient(to bottom, rgba(0,0,0,0.2), rgba(0,0,0,0.62))'
|
||||
: '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%)',
|
||||
? '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,.14) 0%, transparent 54%)',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 opacity-[0.06] pointer-events-none" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '32px' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative -mt-14 md:-mt-16 pb-4 px-1">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:gap-5">
|
||||
<div className="mx-auto z-10 shrink-0 md:mx-0">
|
||||
<img
|
||||
src={user.avatar_url || '/default/avatar_default.webp'}
|
||||
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]"
|
||||
/>
|
||||
</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 className="relative px-4 pb-6 md:px-7 md:pb-7">
|
||||
<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">
|
||||
<img
|
||||
src={user.avatar_url || '/default/avatar_default.webp'}
|
||||
alt={`${uname}'s avatar`}
|
||||
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="mt-2 flex flex-wrap items-center justify-center gap-2.5 text-xs text-slate-400 md:justify-start">
|
||||
{countryName ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1">
|
||||
{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={(event) => { event.target.style.display = 'none' }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 text-center md:text-left">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_280px] xl:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 md:justify-start">
|
||||
<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">
|
||||
<i className="fa-solid fa-stars text-[10px] text-sky-300" />
|
||||
Profile spotlight
|
||||
</span>
|
||||
</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}
|
||||
{countryName}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{joinDate ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1">
|
||||
<i className="fa-solid fa-calendar-days fa-fw opacity-70" />
|
||||
Joined {joinDate}
|
||||
</span>
|
||||
) : 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-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>
|
||||
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
|
||||
export const TABS = [
|
||||
{ id: 'posts', label: 'Posts', icon: 'fa-newspaper' },
|
||||
{ id: 'artworks', label: 'Artworks', icon: 'fa-images' },
|
||||
{ id: 'stories', label: 'Stories', icon: 'fa-feather-pointed' },
|
||||
{ id: 'achievements', label: 'Achievements', icon: 'fa-trophy' },
|
||||
{ id: 'posts', label: 'Posts', icon: 'fa-newspaper' },
|
||||
{ id: 'collections', label: 'Collections', icon: 'fa-layer-group' },
|
||||
{ id: 'about', label: 'About', icon: 'fa-id-card' },
|
||||
{ id: 'stats', label: 'Stats', icon: 'fa-chart-bar' },
|
||||
@@ -23,7 +23,6 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
|
||||
const navRef = useRef(null)
|
||||
const activeRef = useRef(null)
|
||||
|
||||
// Scroll active tab into view on mount/change
|
||||
useEffect(() => {
|
||||
if (activeRef.current && navRef.current) {
|
||||
activeRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
|
||||
@@ -31,13 +30,14 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
|
||||
}, [activeTab])
|
||||
|
||||
return (
|
||||
<nav
|
||||
ref={navRef}
|
||||
className="profile-tabs-sticky sticky z-30 bg-[#0c1525]/95 backdrop-blur-xl border-b border-white/10 overflow-x-auto scrollbar-hide"
|
||||
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="sticky top-0 z-30 border-b border-white/10 bg-[#08111f]/80 backdrop-blur-2xl">
|
||||
<nav
|
||||
ref={navRef}
|
||||
className="profile-tabs-sticky overflow-x-auto scrollbar-hide"
|
||||
aria-label="Profile sections"
|
||||
role="tablist"
|
||||
>
|
||||
<div className="mx-auto flex w-max min-w-full gap-2 px-3 py-3 justify-center xl:items-stretch">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = activeTab === tab.id
|
||||
return (
|
||||
@@ -49,28 +49,29 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
|
||||
aria-selected={isActive}
|
||||
aria-controls={`tabpanel-${tab.id}`}
|
||||
className={`
|
||||
relative flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap rounded-lg
|
||||
transition-colors duration-150 outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-sky-400/70 rounded-t
|
||||
group relative flex items-center gap-2.5 rounded-2xl border px-3.5 py-3 text-sm font-medium whitespace-nowrap
|
||||
outline-none transition-all duration-150 focus-visible:ring-2 focus-visible:ring-sky-400/70
|
||||
${isActive
|
||||
? 'text-white bg-white/[0.05]'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-white/[0.03]'
|
||||
? '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)]'
|
||||
: '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}
|
||||
{/* Active indicator bar */}
|
||||
{isActive && (
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,100 @@ const SOCIAL_ICONS = {
|
||||
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 }) {
|
||||
return (
|
||||
<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
|
||||
* 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 displayName = user.name || uname
|
||||
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 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
|
||||
? 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 (
|
||||
<div
|
||||
id="tabpanel-about"
|
||||
role="tabpanel"
|
||||
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 */}
|
||||
{about ? (
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 mb-5 shadow-xl shadow-black/20 backdrop-blur">
|
||||
<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 className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryCards.map((card) => (
|
||||
<StatCard key={card.label} {...card} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Social links */}
|
||||
{socialEntries.length > 0 && (
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-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-share-nodes text-sky-400 fa-fw" />
|
||||
Social Links
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{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 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"
|
||||
aria-label={si.label}
|
||||
>
|
||||
<i className={`${si.icon} fa-fw`} />
|
||||
<span>{si.label}</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_380px]">
|
||||
<div className="space-y-6">
|
||||
<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))]">
|
||||
{about ? (
|
||||
<p className="whitespace-pre-line text-[15px] leading-8 text-slate-200/90">{about}</p>
|
||||
) : (
|
||||
<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">
|
||||
This creator has not written a public bio yet.
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon="fa-solid fa-address-card" eyebrow="Details" title="Profile information">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{displayName && displayName !== uname ? (
|
||||
<InfoRow icon="fa-user" label="Display name">{displayName}</InfoRow>
|
||||
) : null}
|
||||
<InfoRow icon="fa-at" label="Username"><span className="font-mono">@{uname}</span></InfoRow>
|
||||
{genderLabel ? <InfoRow icon="fa-venus-mars" label="Gender">{genderLabel}</InfoRow> : null}
|
||||
{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="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 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,286 @@
|
||||
import React from 'react'
|
||||
import ProfileGalleryPanel from '../ProfileGalleryPanel'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
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 (
|
||||
<div
|
||||
id="tabpanel-artworks"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-artworks"
|
||||
className="pt-6"
|
||||
className="mx-auto max-w-7xl px-4 pt-2 pb-10 md:px-6"
|
||||
>
|
||||
<ProfileGalleryPanel
|
||||
artworks={artworks}
|
||||
featuredArtworks={featuredArtworks}
|
||||
username={username}
|
||||
<FeaturedShowcase featuredArtworks={featuredArtworks ?? []} />
|
||||
|
||||
<PreviewRail
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,24 +5,50 @@ import PostComposer from '../../Feed/PostComposer'
|
||||
import PostCardSkeleton from '../../Feed/PostCardSkeleton'
|
||||
import FeedSidebar from '../../Feed/FeedSidebar'
|
||||
|
||||
function formatCompactNumber(value) {
|
||||
return Number(value ?? 0).toLocaleString()
|
||||
}
|
||||
|
||||
function EmptyPostsState({ isOwner, username }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center 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="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="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" />
|
||||
</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 ? (
|
||||
<p className="text-slate-600 text-sm max-w-xs">
|
||||
Share updates or showcase your artworks.
|
||||
<p className="max-w-sm text-sm leading-relaxed text-slate-400">
|
||||
Share works in progress, announce releases, or add a bit of personality beyond the gallery.
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
* Profile Posts tab — shows the user's post feed with optional composer (for owner).
|
||||
@@ -51,6 +77,7 @@ export default function TabPosts({
|
||||
recentFollowers,
|
||||
socialLinks,
|
||||
countryName,
|
||||
profileUrl,
|
||||
onTabChange,
|
||||
}) {
|
||||
const [posts, setPosts] = useState([])
|
||||
@@ -58,21 +85,22 @@ export default function TabPosts({
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
// Fetch on mount
|
||||
React.useEffect(() => {
|
||||
fetchFeed(1)
|
||||
}, [username])
|
||||
|
||||
const fetchFeed = async (p = 1) => {
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
try {
|
||||
const { data } = await axios.get(`/api/posts/profile/${username}`, { params: { page: p } })
|
||||
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
|
||||
setHasMore(data.meta.current_page < data.meta.last_page)
|
||||
setPage(p)
|
||||
} catch {
|
||||
//
|
||||
setError(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
@@ -87,28 +115,94 @@ export default function TabPosts({
|
||||
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 (
|
||||
<div className="flex gap-6 py-4 items-start">
|
||||
{/* ── Main feed column ──────────────────────────────────────────────── */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
{/* Composer (owner only) */}
|
||||
<div className="py-6">
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_20rem]">
|
||||
<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)]">
|
||||
<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 && (
|
||||
<PostComposer user={authUser} onPosted={handlePosted} />
|
||||
)}
|
||||
|
||||
{/* Skeletons while loading */}
|
||||
{!loaded && loading && (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => <PostCardSkeleton key={i} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{loaded && !loading && posts.length === 0 && (
|
||||
{loaded && error && posts.length === 0 && (
|
||||
<ErrorPostsState onRetry={() => fetchFeed(1)} />
|
||||
)}
|
||||
|
||||
{loaded && !loading && !error && posts.length === 0 && (
|
||||
<EmptyPostsState isOwner={isOwner} username={username} />
|
||||
)}
|
||||
|
||||
{/* Post list */}
|
||||
{posts.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{posts.map((post) => (
|
||||
@@ -123,13 +217,12 @@ export default function TabPosts({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{loaded && hasMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<button
|
||||
onClick={() => fetchFeed(page + 1)}
|
||||
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 ? (
|
||||
<><i className="fa-solid fa-spinner fa-spin mr-2" />Loading…</>
|
||||
@@ -137,22 +230,22 @@ export default function TabPosts({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Sidebar ───────────────────────────────────────────────────────── */}
|
||||
<aside className="w-72 xl:w-80 shrink-0 hidden lg:block sticky top-20 self-start">
|
||||
<FeedSidebar
|
||||
user={user}
|
||||
profile={profile}
|
||||
stats={stats}
|
||||
followerCount={followerCount}
|
||||
recentFollowers={recentFollowers}
|
||||
socialLinks={socialLinks}
|
||||
countryName={countryName}
|
||||
isLoggedIn={!!authUser}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
</aside>
|
||||
<aside className="hidden xl:block xl:sticky xl:top-24">
|
||||
<FeedSidebar
|
||||
user={user}
|
||||
profile={profile}
|
||||
stats={stats}
|
||||
followerCount={followerCount}
|
||||
recentFollowers={recentFollowers}
|
||||
socialLinks={socialLinks}
|
||||
countryName={countryName}
|
||||
isLoggedIn={!!authUser}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
88
resources/js/components/social/MessageInboxBadge.jsx
Normal file
88
resources/js/components/social/MessageInboxBadge.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
var socialRoot = document.getElementById('story-social-root');
|
||||
if (socialRoot && socialRoot.dataset.reactMounted !== 'true') {
|
||||
@@ -130,6 +147,7 @@ function mountStorySocial() {
|
||||
});
|
||||
}
|
||||
|
||||
mountToolbarMessages();
|
||||
mountToolbarNotifications();
|
||||
mountStorySocial();
|
||||
|
||||
|
||||
@@ -202,16 +202,14 @@
|
||||
@endif
|
||||
</a>
|
||||
|
||||
<a href="{{ Route::has('messages.index') ? route('messages.index') : '/messages' }}"
|
||||
class="relative w-10 h-10 inline-flex items-center justify-center rounded-lg hover:bg-white/5"
|
||||
title="Messages">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
|
||||
@if(($msgCount ?? 0) > 0)
|
||||
<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>
|
||||
@endif
|
||||
</a>
|
||||
@php
|
||||
$toolbarMessagesProps = [
|
||||
'initialUnreadCount' => (int) ($msgCount ?? 0),
|
||||
'userId' => (int) ($userId ?? Auth::id() ?? 0),
|
||||
'href' => Route::has('messages.index') ? route('messages.index') : '/messages',
|
||||
];
|
||||
@endphp
|
||||
<div id="toolbar-messages-root" data-props='@json($toolbarMessagesProps)'></div>
|
||||
|
||||
<div id="toolbar-notification-root" data-props='@json(['initialUnreadCount' => (int) ($noticeCount ?? 0)])'></div>
|
||||
</div>
|
||||
|
||||
@@ -490,6 +490,10 @@ Route::middleware(['web', 'auth', 'normalize.username', 'throttle:60,1'])
|
||||
->prefix('messages')
|
||||
->name('api.messages.')
|
||||
->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::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::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}/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');
|
||||
@@ -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::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::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/stop', [\App\Http\Controllers\Api\Messaging\TypingController::class, 'stop'])->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::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'])->middleware('throttle:messages-typing')->whereNumber('conversation_id')->name('typing.stop');
|
||||
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::delete('{conversation_id}/{message_id}/react', [\App\Http\Controllers\Api\Messaging\MessageController::class, 'unreact'])->whereNumber(['conversation_id', 'message_id'])->name('unreact');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Models\Conversation;
|
||||
use App\Policies\ConversationPolicy;
|
||||
use App\Services\Messaging\MessagingPayloadFactory;
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
|
||||
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
|
||||
@@ -43,10 +44,9 @@ Broadcast::channel('presence-conversation.{conversationId}', function ($user, $c
|
||||
return false;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $user->id,
|
||||
'username' => (string) $user->username,
|
||||
'display_name' => (string) ($user->name ?: $user->username),
|
||||
'avatar_thumb_url' => null,
|
||||
];
|
||||
return app(MessagingPayloadFactory::class)->presenceUser($user);
|
||||
});
|
||||
|
||||
Broadcast::channel('presence-messaging', function ($user) {
|
||||
return app(MessagingPayloadFactory::class)->presenceUser($user);
|
||||
});
|
||||
|
||||
@@ -133,3 +133,8 @@ Schedule::command('forum:firewall-scan')
|
||||
->name('forum-firewall-scan')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
Schedule::command('horizon:snapshot')
|
||||
->everyFiveMinutes()
|
||||
->name('horizon-snapshot')
|
||||
->withoutOverlapping();
|
||||
|
||||
@@ -236,6 +236,11 @@ Route::get('/@{username}/gallery', [ProfileController::class, 'showGalleryByUser
|
||||
->where('username', '[A-Za-z0-9_-]{3,20}')
|
||||
->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'])
|
||||
->where('username', '[A-Za-z0-9_-]{3,20}')
|
||||
->name('profile.show');
|
||||
|
||||
@@ -12,8 +12,10 @@ use App\Policies\ConversationPolicy;
|
||||
use App\Events\ConversationUpdated;
|
||||
use App\Events\MessageCreated;
|
||||
use App\Events\MessageRead;
|
||||
use App\Services\Messaging\MessagingPresenceService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
@@ -259,6 +261,133 @@ test('typing endpoints reject non participants', function () {
|
||||
->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 () {
|
||||
$userA = makeMessagingUser();
|
||||
$userB = makeMessagingUser();
|
||||
|
||||
122
tests/Feature/Vision/ArtworkVectorGatewayCommandsTest.php
Normal file
122
tests/Feature/Vision/ArtworkVectorGatewayCommandsTest.php
Normal 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();
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
Reference in New Issue
Block a user