Compare commits

...

5 Commits

Author SHA1 Message Date
73260e7eae updated gitignore and .env.example 2026-03-28 09:20:02 +01:00
2608be7420 Repair: copy legacy joinDate into new user's created_at when creating users from legacy wallz 2026-03-22 09:13:39 +01:00
e8b5edf5d2 feat: add Reverb realtime messaging 2026-03-21 12:51:59 +01:00
60f78e8235 Add Laravel broadcasting setup
Register channel routes and add the default broadcasting configuration generated for Laravel broadcasting support.
2026-03-21 11:08:18 +01:00
979e011257 Refactor dashboard and upload flows
Remove dead admin UI code, redesign dashboard followers/following and upload experiences, and add schema audit tooling with repair migrations for forum and upload drift.
2026-03-21 11:02:22 +01:00
152 changed files with 10208 additions and 2872 deletions

View File

@@ -41,9 +41,39 @@ SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database 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
REVERB_APP_SECRET=skinbase-local-secret
REVERB_HOST=127.0.0.1
REVERB_PORT=8080
REVERB_SCHEME=http
REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080
REVERB_SERVER_PATH=
REVERB_SCALING_ENABLED=false
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
# Upload UI feature flag (legacy upload remains default unless explicitly enabled) # Upload UI feature flag (legacy upload remains default unless explicitly enabled)
SKINBASE_UPLOADS_V2=false SKINBASE_UPLOADS_V2=false
@@ -57,6 +87,21 @@ SKINBASE_DUPLICATE_HASH_POLICY=block
VISION_ENABLED=true VISION_ENABLED=true
VISION_QUEUE=default VISION_QUEUE=default
VISION_IMAGE_VARIANT=md VISION_IMAGE_VARIANT=md
VISION_GATEWAY_URL=
VISION_GATEWAY_TIMEOUT=10
VISION_GATEWAY_CONNECT_TIMEOUT=3
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
VISION_VECTOR_GATEWAY_UPSERT_ENDPOINT=/vectors/upsert
VISION_VECTOR_GATEWAY_SEARCH_ENDPOINT=/vectors/search
VISION_VECTOR_GATEWAY_DELETE_ENDPOINT=/vectors/delete
VISION_VECTOR_GATEWAY_COLLECTIONS_ENDPOINT=/vectors/collections
# CLIP service (set base URL to enable CLIP calls) # CLIP service (set base URL to enable CLIP calls)
CLIP_BASE_URL= CLIP_BASE_URL=
@@ -81,6 +126,8 @@ RECOMMENDATIONS_AB_ALGO_VERSIONS=clip-cosine-v1
RECOMMENDATIONS_MIN_DIM=64 RECOMMENDATIONS_MIN_DIM=64
RECOMMENDATIONS_MAX_DIM=4096 RECOMMENDATIONS_MAX_DIM=4096
RECOMMENDATIONS_BACKFILL_BATCH=200 RECOMMENDATIONS_BACKFILL_BATCH=200
SIMILARITY_VECTOR_ENABLED=false
SIMILARITY_VECTOR_ADAPTER=pgvector
# Personalized discovery foundation (Phase 8) # Personalized discovery foundation (Phase 8)
DISCOVERY_QUEUE=${RECOMMENDATIONS_QUEUE} DISCOVERY_QUEUE=${RECOMMENDATIONS_QUEUE}
@@ -94,6 +141,16 @@ DISCOVERY_WEIGHT_CLICK=2
DISCOVERY_WEIGHT_FAVORITE=4 DISCOVERY_WEIGHT_FAVORITE=4
DISCOVERY_WEIGHT_DOWNLOAD=3 DISCOVERY_WEIGHT_DOWNLOAD=3
DISCOVERY_CACHE_TTL_MINUTES=60 DISCOVERY_CACHE_TTL_MINUTES=60
DISCOVERY_V3_ENABLED=false
DISCOVERY_V3_CACHE_VERSION=cache-v3
DISCOVERY_V3_CACHE_TTL_MINUTES=5
DISCOVERY_V3_VECTOR_SIMILARITY_WEIGHT=0.8
DISCOVERY_V3_VECTOR_BASE_SCORE=0.75
DISCOVERY_V3_MAX_SEED_ARTWORKS=3
DISCOVERY_V3_VECTOR_CANDIDATE_POOL=60
DISCOVERY_V3_SECTION_SIMILAR_STYLE_LIMIT=3
DISCOVERY_V3_SECTION_YOU_MAY_ALSO_LIKE_LIMIT=6
DISCOVERY_V3_SECTION_VISUALLY_RELATED_LIMIT=6
DISCOVERY_RANKING_WEIGHTS_VERSION=rank-w-v1 DISCOVERY_RANKING_WEIGHTS_VERSION=rank-w-v1
DISCOVERY_RANKING_W1=0.65 DISCOVERY_RANKING_W1=0.65
DISCOVERY_RANKING_W2=0.20 DISCOVERY_RANKING_W2=0.20
@@ -145,6 +202,9 @@ YOLO_PHOTOGRAPHY_ONLY=true
# VISION_ENABLED=true # VISION_ENABLED=true
# VISION_QUEUE=vision # VISION_QUEUE=vision
# VISION_IMAGE_VARIANT=md # VISION_IMAGE_VARIANT=md
# VISION_GATEWAY_URL=https://vision.internal
# VISION_GATEWAY_TIMEOUT=8
# VISION_GATEWAY_CONNECT_TIMEOUT=2
# #
# CLIP_BASE_URL=https://clip.internal # CLIP_BASE_URL=https://clip.internal
# CLIP_ANALYZE_ENDPOINT=/analyze # CLIP_ANALYZE_ENDPOINT=/analyze
@@ -174,6 +234,16 @@ YOLO_PHOTOGRAPHY_ONLY=true
# DISCOVERY_WEIGHT_CLICK=2 # DISCOVERY_WEIGHT_CLICK=2
# DISCOVERY_WEIGHT_FAVORITE=4 # DISCOVERY_WEIGHT_FAVORITE=4
# DISCOVERY_WEIGHT_DOWNLOAD=3 # DISCOVERY_WEIGHT_DOWNLOAD=3
# DISCOVERY_V3_ENABLED=true
# DISCOVERY_V3_CACHE_VERSION=cache-v3
# DISCOVERY_V3_CACHE_TTL_MINUTES=5
# DISCOVERY_V3_VECTOR_SIMILARITY_WEIGHT=0.8
# DISCOVERY_V3_VECTOR_BASE_SCORE=0.75
# DISCOVERY_V3_MAX_SEED_ARTWORKS=3
# DISCOVERY_V3_VECTOR_CANDIDATE_POOL=60
# DISCOVERY_V3_SECTION_SIMILAR_STYLE_LIMIT=3
# DISCOVERY_V3_SECTION_YOU_MAY_ALSO_LIKE_LIMIT=6
# DISCOVERY_V3_SECTION_VISUALLY_RELATED_LIMIT=6
# DISCOVERY_RANKING_WEIGHTS_VERSION=rank-w-v1 # DISCOVERY_RANKING_WEIGHTS_VERSION=rank-w-v1
# DISCOVERY_RANKING_W1=0.65 # DISCOVERY_RANKING_W1=0.65
# DISCOVERY_RANKING_W2=0.20 # DISCOVERY_RANKING_W2=0.20

25
.gitignore vendored
View File

@@ -19,6 +19,31 @@
/public/files /public/files
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/storage/app
/storage/framework/cache
/storage/framework/sessions
/storage/framework/views
/storage/logs
/storage/testing
/storage/*.log
/storage/*.key
/storage/*.sqlite
/storage/*.sqlite3
/storage/*.zip
/storage/*.tar.gz
/storage/*.tar.gz
/storage/*.tar.bz2
/storage/*.tar.xz
/storage/*.tar
/storage/*.tgz
/storage/*.tbz2
/storage/*.txz
/storage/*.zip
/storage/*.tar.gz
/storage/*.tar.bz2
/storage/*.tar.xz
/storage/*.tar
/storage/*.tgz
/vendor /vendor
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml

View File

@@ -0,0 +1,519 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\Finder\Finder;
class AuditMigrationSchemaCommand extends Command
{
protected $signature = 'schema:audit-migrations
{--all-files : Audit all discovered migration files, not only migrations marked as ran}
{--json : Output the report as JSON}
{--base-path=* : Additional base paths to scan for migrations, relative to project root}';
protected $description = 'Compare the live database schema against executed migration files and report missing tables or columns';
private const NO_ARG_COLUMN_METHODS = [
'id' => ['id'],
'timestamps' => ['created_at', 'updated_at'],
'timestampsTz' => ['created_at', 'updated_at'],
'softDeletes' => ['deleted_at'],
'softDeletesTz' => ['deleted_at'],
'rememberToken' => ['remember_token'],
];
private const NON_COLUMN_METHODS = [
'index',
'unique',
'primary',
'foreign',
'foreignIdFor',
'dropColumn',
'dropColumns',
'dropIndex',
'dropUnique',
'dropPrimary',
'dropForeign',
'dropConstrainedForeignId',
'renameColumn',
'renameIndex',
'constrained',
'cascadeOnDelete',
'restrictOnDelete',
'nullOnDelete',
'cascadeOnUpdate',
'restrictOnUpdate',
'nullOnUpdate',
'after',
'nullable',
'default',
'useCurrent',
'useCurrentOnUpdate',
'comment',
'charset',
'collation',
'storedAs',
'virtualAs',
'generatedAs',
'always',
'invisible',
'first',
];
public function handle(): int
{
$migrationFiles = $this->discoverMigrationFiles();
$ranMigrations = collect(DB::table('migrations')->pluck('migration')->all())
->mapWithKeys(fn (string $migration): array => [$migration => true])
->all();
$expected = [];
$parsedFiles = 0;
foreach ($migrationFiles as $migrationName => $path) {
if (! $this->option('all-files') && ! isset($ranMigrations[$migrationName])) {
continue;
}
$parsedFiles++;
$operations = $this->parseMigrationFile($path);
foreach ($operations as $operation) {
$table = $operation['table'];
if ($operation['type'] === 'create-table' && isset($expected[$table])) {
$expected[$table]['sources'][$migrationName] = true;
if (Schema::hasTable($table)) {
$actualColumns = array_fill_keys(
array_map('strtolower', Schema::getColumnListing($table)),
true
);
$existingColumns = array_fill_keys(array_keys($expected[$table]['columns']), true);
$replacementColumns = [];
foreach ($operation['add'] as $column) {
if (! isset($existingColumns[$column]) && isset($actualColumns[$column])) {
$replacementColumns[$column] = true;
}
}
if ($replacementColumns !== []) {
foreach ($replacementColumns as $column => $_) {
$expected[$table]['columns'][$column] = true;
}
foreach (array_keys($expected[$table]['columns']) as $column) {
if (! isset($actualColumns[$column]) && ! isset($replacementColumns[$column])) {
unset($expected[$table]['columns'][$column]);
}
}
}
}
continue;
}
if ($operation['type'] === 'alter-table' && ! isset($expected[$table]) && ! Schema::hasTable($table)) {
continue;
}
$expected[$table] ??= [
'columns' => [],
'sources' => [],
];
$expected[$table]['sources'][$migrationName] = true;
if ($operation['type'] === 'drop-table') {
unset($expected[$table]);
continue;
}
foreach ($operation['add'] as $column) {
$expected[$table]['columns'][$column] = true;
}
foreach ($operation['drop'] as $column) {
unset($expected[$table]['columns'][$column]);
}
}
}
ksort($expected);
$report = [
'parsed_files' => $parsedFiles,
'expected_tables' => count($expected),
'missing_tables' => [],
'missing_columns' => [],
];
foreach ($expected as $table => $spec) {
$sources = array_keys($spec['sources']);
sort($sources);
if (! Schema::hasTable($table)) {
$report['missing_tables'][] = [
'table' => $table,
'sources' => $sources,
];
continue;
}
$actualColumns = array_map('strtolower', Schema::getColumnListing($table));
$expectedColumns = array_keys($spec['columns']);
sort($expectedColumns);
$missing = array_values(array_diff($expectedColumns, $actualColumns));
if ($missing !== []) {
$report['missing_columns'][] = [
'table' => $table,
'columns' => $missing,
'sources' => $sources,
];
}
}
if ((bool) $this->option('json')) {
$this->line(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
} else {
$this->renderReport($report);
}
return ($report['missing_tables'] === [] && $report['missing_columns'] === [])
? self::SUCCESS
: self::FAILURE;
}
/**
* @return array<string, string>
*/
private function discoverMigrationFiles(): array
{
$paths = [
database_path('migrations'),
base_path('packages/klevze'),
];
foreach ((array) $this->option('base-path') as $relativePath) {
$resolved = base_path((string) $relativePath);
if (is_dir($resolved)) {
$paths[] = $resolved;
}
}
$finder = new Finder();
$finder->files()->name('*.php');
foreach ($paths as $path) {
if (is_dir($path)) {
$finder->in($path);
}
}
$files = [];
foreach ($finder as $file) {
$realPath = $file->getRealPath();
if (! $realPath) {
continue;
}
$normalized = str_replace('\\', '/', $realPath);
if (! str_contains($normalized, '/database/migrations/') && ! str_contains($normalized, '/Migrations/')) {
continue;
}
$files[pathinfo($realPath, PATHINFO_FILENAME)] = $realPath;
}
ksort($files);
return $files;
}
/**
* @return array<int, array{type:string, table:string, add:array<int,string>, drop:array<int,string>}>
*/
private function parseMigrationFile(string $path): array
{
$content = File::get($path);
$upBody = $this->extractMethodBody($content, 'up');
if ($upBody === null) {
return [];
}
$operations = [];
foreach ($this->extractSchemaClosures($upBody) as $closure) {
$operations[] = [
'type' => $closure['operation'],
'table' => $closure['table'],
'add' => $this->extractAddedColumns($closure['body']),
'drop' => $this->extractDroppedColumns($closure['body']),
];
}
if (preg_match_all("/Schema::drop(?:IfExists)?\(\s*['\"]([^'\"]+)['\"]\s*\)/", $upBody, $matches)) {
foreach ($matches[1] as $table) {
$operations[] = [
'type' => 'drop-table',
'table' => strtolower((string) $table),
'add' => [],
'drop' => [],
];
}
}
foreach ($this->extractRawAlterTableChanges($upBody) as $change) {
$operations[] = [
'type' => 'alter-table',
'table' => $change['table'],
'add' => [$change['new_column']],
'drop' => [$change['old_column']],
];
}
return $operations;
}
/**
* @return array<int, array{table:string, old_column:string, new_column:string}>
*/
private function extractRawAlterTableChanges(string $upBody): array
{
$changes = [];
if (preg_match_all(
'/ALTER\s+TABLE\s+[`"]?([^`"\s]+)[`"]?\s+CHANGE(?:\s+COLUMN)?\s+[`"]?([^`"\s]+)[`"]?\s+[`"]?([^`"\s]+)[`"]?/i',
$upBody,
$matches,
PREG_SET_ORDER
)) {
foreach ($matches as $match) {
$oldColumn = strtolower((string) $match[2]);
$newColumn = strtolower((string) $match[3]);
if ($oldColumn === $newColumn) {
continue;
}
$changes[] = [
'table' => strtolower((string) $match[1]),
'old_column' => $oldColumn,
'new_column' => $newColumn,
];
}
}
return $changes;
}
private function extractMethodBody(string $content, string $method): ?string
{
if (! preg_match('/function\s+' . preg_quote($method, '/') . '\s*\([^)]*\)\s*(?::\s*[^{]+)?\s*\{/m', $content, $match, PREG_OFFSET_CAPTURE)) {
return null;
}
$start = $match[0][1] + strlen($match[0][0]) - 1;
$end = $this->findMatchingBrace($content, $start);
if ($end === null) {
return null;
}
return substr($content, $start + 1, $end - $start - 1);
}
private function findMatchingBrace(string $content, int $openingBracePos): ?int
{
$length = strlen($content);
$depth = 0;
$inSingle = false;
$inDouble = false;
for ($index = $openingBracePos; $index < $length; $index++) {
$char = $content[$index];
$prev = $index > 0 ? $content[$index - 1] : '';
if ($char === "'" && ! $inDouble && $prev !== '\\') {
$inSingle = ! $inSingle;
continue;
}
if ($char === '"' && ! $inSingle && $prev !== '\\') {
$inDouble = ! $inDouble;
continue;
}
if ($inSingle || $inDouble) {
continue;
}
if ($char === '{') {
$depth++;
continue;
}
if ($char === '}') {
$depth--;
if ($depth === 0) {
return $index;
}
}
}
return null;
}
/**
* @return array<int, array{operation:string, table:string, body:string}>
*/
private function extractSchemaClosures(string $upBody): array
{
preg_match_all('/Schema::(create|table)\(\s*[\'\"]([^\'\"]+)[\'\"]\s*,\s*function/s', $upBody, $matches, PREG_OFFSET_CAPTURE);
$closures = [];
foreach ($matches[0] as $index => $fullMatch) {
$offset = (int) $fullMatch[1];
$operation = strtolower((string) $matches[1][$index][0]) === 'create' ? 'create-table' : 'alter-table';
$table = strtolower((string) $matches[2][$index][0]);
$bracePos = strpos($upBody, '{', $offset);
if ($bracePos === false) {
continue;
}
$closing = $this->findMatchingBrace($upBody, $bracePos);
if ($closing === null) {
continue;
}
$closures[] = [
'operation' => $operation,
'table' => $table,
'body' => substr($upBody, $bracePos + 1, $closing - $bracePos - 1),
];
}
return $closures;
}
/**
* @return array<int, string>
*/
private function extractAddedColumns(string $body): array
{
$columns = [];
if (preg_match_all('/\$table->([A-Za-z_][A-Za-z0-9_]*)\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$method = (string) $match[1];
$column = strtolower((string) $match[2]);
if (in_array($method, self::NON_COLUMN_METHODS, true)) {
continue;
}
$columns[$column] = true;
}
}
if (preg_match_all('/\$table->([A-Za-z_][A-Za-z0-9_]*)\(\s*\)(?:[^;]*)?;/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$method = (string) $match[1];
foreach (self::NO_ARG_COLUMN_METHODS[$method] ?? [] as $column) {
$columns[$column] = true;
}
}
}
if (preg_match_all('/\$table->(nullableMorphs|morphs|uuidMorphs|nullableUuidMorphs|ulidMorphs|nullableUlidMorphs)\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$prefix = strtolower((string) $match[2]);
$columns[$prefix . '_type'] = true;
$columns[$prefix . '_id'] = true;
}
}
ksort($columns);
return array_keys($columns);
}
/**
* @return array<int, string>
*/
private function extractDroppedColumns(string $body): array
{
$columns = [];
if (preg_match_all('/\$table->dropColumn\(\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches)) {
foreach ($matches[1] as $column) {
$columns[strtolower((string) $column)] = true;
}
}
if (preg_match_all('/\$table->dropColumn\(\s*\[(.*?)\]\s*\);/s', $body, $matches)) {
foreach ($matches[1] as $arrayBody) {
if (preg_match_all('/[\'\"]([^\'\"]+)[\'\"]/', $arrayBody, $columnMatches)) {
foreach ($columnMatches[1] as $column) {
$columns[strtolower((string) $column)] = true;
}
}
}
}
if (preg_match_all('/\$table->renameColumn\(\s*[\'\"]([^\'\"]+)[\'\"]\s*,\s*[\'\"]([^\'\"]+)[\'\"][^;]*\);/s', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$columns[strtolower((string) $match[1])] = true;
}
}
ksort($columns);
return array_keys($columns);
}
/**
* @param array{parsed_files:int, expected_tables:int, missing_tables:array<int,array{table:string,sources:array<int,string>}>, missing_columns:array<int,array{table:string,columns:array<int,string>,sources:array<int,string>}>} $report
*/
private function renderReport(array $report): void
{
$this->info(sprintf(
'Parsed %d migration file(s). Expected schema covers %d table(s).',
$report['parsed_files'],
$report['expected_tables']
));
if ($report['missing_tables'] === [] && $report['missing_columns'] === []) {
$this->info('Schema audit passed. No missing tables or columns detected.');
return;
}
if ($report['missing_tables'] !== []) {
$this->newLine();
$this->error('Missing tables:');
foreach ($report['missing_tables'] as $item) {
$this->line(sprintf(' - %s', $item['table']));
$this->line(sprintf(' sources: %s', implode(', ', $item['sources'])));
}
}
if ($report['missing_columns'] !== []) {
$this->newLine();
$this->error('Missing columns:');
foreach ($report['missing_columns'] as $item) {
$this->line(sprintf(' - %s: %s', $item['table'], implode(', ', $item['columns'])));
$this->line(sprintf(' sources: %s', implode(', ', $item['sources'])));
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
<?php
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;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ConversationUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(
public int $userId,
public Conversation $conversation,
public string $reason,
) {
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
}
public function broadcastOn(): array
{
return [new PrivateChannel('user.' . $this->userId)];
}
public function broadcastAs(): string
{
return 'conversation.updated';
}
public function broadcastWith(): array
{
return [
'event' => 'conversation.updated',
'reason' => $this->reason,
'conversation' => app(MessagingPayloadFactory::class)->conversationSummary($this->conversation, $this->userId),
'summary' => [
'unread_total' => app(UnreadCounterService::class)->totalUnreadForUser($this->userId),
],
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Events;
use App\Models\Conversation;
use App\Models\Message;
use App\Services\Messaging\MessagingPayloadFactory;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageCreated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(
public Conversation $conversation,
public Message $message,
int $originUserId,
) {
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
if ($originUserId === (int) $message->sender_id) {
$this->dontBroadcastToCurrentUser();
}
}
public function broadcastOn(): array
{
return [new PrivateChannel('conversation.' . $this->conversation->id)];
}
public function broadcastAs(): string
{
return 'message.created';
}
public function broadcastWith(): array
{
return [
'event' => 'message.created',
'conversation_id' => (int) $this->conversation->id,
'message' => app(MessagingPayloadFactory::class)->message($this->message, (int) $this->message->sender_id),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Events;
use App\Models\Message;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageDeleted implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(public Message $message)
{
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
$this->dontBroadcastToCurrentUser();
}
public function broadcastOn(): array
{
return [new PrivateChannel('conversation.' . $this->message->conversation_id)];
}
public function broadcastAs(): string
{
return 'message.deleted';
}
public function broadcastWith(): array
{
return [
'event' => 'message.deleted',
'conversation_id' => (int) $this->message->conversation_id,
'message_id' => (int) $this->message->id,
'uuid' => (string) $this->message->uuid,
'deleted_at' => optional($this->message->deleted_at ?? now())?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Events;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\User;
use App\Services\Messaging\MessagingPayloadFactory;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageRead implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(
public Conversation $conversation,
public ConversationParticipant $participant,
public User $reader,
) {
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
$this->dontBroadcastToCurrentUser();
}
public function broadcastOn(): array
{
return [new PrivateChannel('conversation.' . $this->conversation->id)];
}
public function broadcastAs(): string
{
return 'message.read';
}
public function broadcastWith(): array
{
return [
'event' => 'message.read',
'conversation_id' => (int) $this->conversation->id,
'user' => app(MessagingPayloadFactory::class)->userSummary($this->reader),
'last_read_message_id' => $this->participant->last_read_message_id ? (int) $this->participant->last_read_message_id : null,
'last_read_at' => optional($this->participant->last_read_at)?->toIso8601String(),
];
}
}

View File

@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageSent
{
use Dispatchable, SerializesModels;
public function __construct(
public int $conversationId,
public int $messageId,
public int $senderId,
) {}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Events;
use App\Models\Message;
use App\Services\Messaging\MessagingPayloadFactory;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct(public Message $message)
{
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
$this->dontBroadcastToCurrentUser();
}
public function broadcastOn(): array
{
return [new PrivateChannel('conversation.' . $this->message->conversation_id)];
}
public function broadcastAs(): string
{
return 'message.updated';
}
public function broadcastWith(): array
{
return [
'event' => 'message.updated',
'conversation_id' => (int) $this->message->conversation_id,
'message' => app(MessagingPayloadFactory::class)->message($this->message),
];
}
}

View File

@@ -2,15 +2,46 @@
namespace App\Events; namespace App\Events;
use App\Models\User;
use App\Services\Messaging\MessagingPayloadFactory;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class TypingStarted class TypingStarted implements ShouldBroadcast
{ {
use Dispatchable, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct( public function __construct(
public int $conversationId, public int $conversationId,
public int $userId, public User $user,
) {} ) {
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
$this->dontBroadcastToCurrentUser();
}
public function broadcastOn(): array
{
return [new PresenceChannel('conversation.' . $this->conversationId)];
}
public function broadcastAs(): string
{
return 'typing.started';
}
public function broadcastWith(): array
{
return [
'event' => 'typing.started',
'conversation_id' => $this->conversationId,
'user' => app(MessagingPayloadFactory::class)->userSummary($this->user),
'expires_in_ms' => (int) config('messaging.typing.ttl_seconds', 8) * 1000,
];
}
} }

View File

@@ -2,15 +2,45 @@
namespace App\Events; namespace App\Events;
use App\Models\User;
use App\Services\Messaging\MessagingPayloadFactory;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class TypingStopped class TypingStopped implements ShouldBroadcast
{ {
use Dispatchable, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $afterCommit = true;
public string $queue;
public function __construct( public function __construct(
public int $conversationId, public int $conversationId,
public int $userId, public User $user,
) {} ) {
$this->queue = (string) config('messaging.broadcast.queue', 'broadcasts');
$this->dontBroadcastToCurrentUser();
}
public function broadcastOn(): array
{
return [new PresenceChannel('conversation.' . $this->conversationId)];
}
public function broadcastAs(): string
{
return 'typing.stopped';
}
public function broadcastWith(): array
{
return [
'event' => 'typing.stopped',
'conversation_id' => $this->conversationId,
'user' => app(MessagingPayloadFactory::class)->userSummary($this->user),
];
}
} }

View File

@@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Country;
use App\Services\Countries\CountrySyncService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Throwable;
final class CountryAdminController extends Controller
{
public function index(Request $request): View
{
$search = trim((string) $request->query('q', ''));
$countries = Country::query()
->when($search !== '', function ($query) use ($search): void {
$query->where(function ($countryQuery) use ($search): void {
$countryQuery
->where('iso2', 'like', '%'.$search.'%')
->orWhere('iso3', 'like', '%'.$search.'%')
->orWhere('name_common', 'like', '%'.$search.'%')
->orWhere('name_official', 'like', '%'.$search.'%');
});
})
->ordered()
->paginate(50)
->withQueryString();
return view('admin.countries.index', [
'countries' => $countries,
'search' => $search,
]);
}
public function sync(Request $request, CountrySyncService $countrySyncService): RedirectResponse
{
try {
$summary = $countrySyncService->sync();
} catch (Throwable $exception) {
return redirect()
->route('admin.countries.index')
->with('error', $exception->getMessage());
}
$message = sprintf(
'Countries synced from %s. Inserted %d, updated %d, skipped %d, deactivated %d.',
(string) ($summary['source'] ?? 'unknown'),
(int) ($summary['inserted'] ?? 0),
(int) ($summary['updated'] ?? 0),
(int) ($summary['skipped'] ?? 0),
(int) ($summary['deactivated'] ?? 0),
);
return redirect()
->route('admin.countries.index')
->with('success', $message);
}
public function cpMain(Request $request): View
{
$view = $this->index($request);
return view('admin.countries.cpad', $view->getData());
}
public function cpSync(Request $request, CountrySyncService $countrySyncService): RedirectResponse
{
try {
$summary = $countrySyncService->sync();
} catch (Throwable $exception) {
return redirect()
->route('admin.cp.countries.main')
->with('msg_error', $exception->getMessage());
}
$message = sprintf(
'Countries synced from %s. Inserted %d, updated %d, skipped %d, deactivated %d.',
(string) ($summary['source'] ?? 'unknown'),
(int) ($summary['inserted'] ?? 0),
(int) ($summary['updated'] ?? 0),
(int) ($summary['skipped'] ?? 0),
(int) ($summary['deactivated'] ?? 0),
);
return redirect()
->route('admin.cp.countries.main')
->with('msg_success', $message);
}
}

View File

@@ -1,119 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\EarlyGrowth\ActivityLayer;
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
use App\Services\EarlyGrowth\EarlyGrowth;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
/**
* EarlyGrowthAdminController (§14)
*
* Admin panel for the Early-Stage Growth System.
* All toggles are ENV-driven; updating .env requires a deploy.
* This panel provides a read-only status view plus a cache-flush action.
*
* Future v2: wire to a `settings` DB table so admins can toggle without
* a deploy. The EarlyGrowth::enabled() contract already supports this.
*/
final class EarlyGrowthAdminController extends Controller
{
public function __construct(
private readonly AdaptiveTimeWindow $timeWindow,
private readonly ActivityLayer $activityLayer,
) {}
/**
* GET /admin/early-growth
* Status dashboard: shows current config, live stats, toggle instructions.
*/
public function index(): View
{
$uploadsPerDay = $this->timeWindow->getUploadsPerDay();
return view('admin.early-growth.index', [
'status' => EarlyGrowth::status(),
'mode' => EarlyGrowth::mode(),
'uploads_per_day' => $uploadsPerDay,
'window_days' => $this->timeWindow->getTrendingWindowDays(30),
'activity' => $this->activityLayer->getSignals(),
'cache_keys' => [
'egs.uploads_per_day',
'egs.auto_disable_check',
'egs.spotlight.*',
'egs.curated.*',
'egs.grid_filler.*',
'egs.activity_signals',
'homepage.fresh.*',
'discover.trending.*',
'discover.rising.*',
],
'env_toggles' => [
['key' => 'NOVA_EARLY_GROWTH_ENABLED', 'current' => env('NOVA_EARLY_GROWTH_ENABLED', 'false')],
['key' => 'NOVA_EARLY_GROWTH_MODE', 'current' => env('NOVA_EARLY_GROWTH_MODE', 'off')],
['key' => 'NOVA_EGS_ADAPTIVE_WINDOW', 'current' => env('NOVA_EGS_ADAPTIVE_WINDOW', 'true')],
['key' => 'NOVA_EGS_GRID_FILLER', 'current' => env('NOVA_EGS_GRID_FILLER', 'true')],
['key' => 'NOVA_EGS_SPOTLIGHT', 'current' => env('NOVA_EGS_SPOTLIGHT', 'true')],
['key' => 'NOVA_EGS_ACTIVITY_LAYER', 'current' => env('NOVA_EGS_ACTIVITY_LAYER', 'false')],
],
]);
}
/**
* DELETE /admin/early-growth/cache
* Flush all EGS-related cache keys so new config changes take effect immediately.
*/
public function flushCache(Request $request): RedirectResponse
{
$keys = [
'egs.uploads_per_day',
'egs.auto_disable_check',
'egs.activity_signals',
];
// Flush the EGS daily spotlight caches for today
$today = now()->format('Y-m-d');
foreach ([6, 12, 18, 24] as $n) {
Cache::forget("egs.spotlight.{$today}.{$n}");
Cache::forget("egs.curated.{$today}.{$n}.7");
}
// Flush fresh/trending homepage sections
foreach ([6, 8, 10, 12] as $limit) {
foreach (['off', 'light', 'aggressive'] as $mode) {
Cache::forget("homepage.fresh.{$limit}.egs-{$mode}");
Cache::forget("homepage.fresh.{$limit}.std");
}
Cache::forget("homepage.trending.{$limit}");
Cache::forget("homepage.rising.{$limit}");
}
// Flush key keys
foreach ($keys as $key) {
Cache::forget($key);
}
return redirect()->route('admin.early-growth.index')
->with('success', 'Early Growth System cache flushed. Changes will take effect on next page load.');
}
/**
* GET /admin/early-growth/status (JSON for monitoring/healthcheck)
*/
public function status(): JsonResponse
{
return response()->json([
'egs' => EarlyGrowth::status(),
'uploads_per_day' => $this->timeWindow->getUploadsPerDay(),
'window_days' => $this->timeWindow->getTrendingWindowDays(30),
]);
}
}

View File

@@ -1,211 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Models\StoryTag;
use App\Models\User;
use App\Notifications\StoryStatusNotification;
use App\Services\StoryPublicationService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class StoryAdminController extends Controller
{
public function index(): View
{
$stories = Story::query()
->with(['creator'])
->latest('created_at')
->paginate(25);
return view('admin.stories.index', ['stories' => $stories]);
}
public function review(): View
{
$stories = Story::query()
->with(['creator'])
->where('status', 'pending_review')
->orderByDesc('submitted_for_review_at')
->paginate(25);
return view('admin.stories.review', ['stories' => $stories]);
}
public function create(): View
{
return view('admin.stories.create', [
'creators' => User::query()->orderBy('username')->limit(200)->get(['id', 'username']),
'tags' => StoryTag::query()->orderBy('name')->get(['id', 'name']),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'creator_id' => ['required', 'integer', 'exists:users,id'],
'title' => ['required', 'string', 'max:255'],
'excerpt' => ['nullable', 'string', 'max:500'],
'cover_image' => ['nullable', 'string', 'max:500'],
'content' => ['required', 'string'],
'story_type' => ['required', 'in:creator_story,tutorial,interview,project_breakdown,announcement,resource'],
'status' => ['required', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])],
'tags' => ['nullable', 'array'],
'tags.*' => ['integer', 'exists:story_tags,id'],
]);
$story = Story::query()->create([
'creator_id' => (int) $validated['creator_id'],
'title' => $validated['title'],
'slug' => $this->uniqueSlug($validated['title']),
'excerpt' => $validated['excerpt'] ?? null,
'cover_image' => $validated['cover_image'] ?? null,
'content' => $validated['content'],
'story_type' => $validated['story_type'],
'reading_time' => max(1, (int) ceil(str_word_count(strip_tags((string) $validated['content'])) / 200)),
'status' => $validated['status'],
'published_at' => $validated['status'] === 'published' ? now() : null,
'submitted_for_review_at' => $validated['status'] === 'pending_review' ? now() : null,
]);
if (! empty($validated['tags'])) {
$story->tags()->sync($validated['tags']);
}
if ($validated['status'] === 'published') {
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
}
return redirect()->route('admin.stories.edit', ['story' => $story->id])
->with('status', 'Story created.');
}
public function edit(Story $story): View
{
$story->load('tags');
return view('admin.stories.edit', [
'story' => $story,
'creators' => User::query()->orderBy('username')->limit(200)->get(['id', 'username']),
'tags' => StoryTag::query()->orderBy('name')->get(['id', 'name']),
]);
}
public function update(Request $request, Story $story): RedirectResponse
{
$wasPublished = $story->published_at !== null || $story->status === 'published';
$validated = $request->validate([
'creator_id' => ['required', 'integer', 'exists:users,id'],
'title' => ['required', 'string', 'max:255'],
'excerpt' => ['nullable', 'string', 'max:500'],
'cover_image' => ['nullable', 'string', 'max:500'],
'content' => ['required', 'string'],
'story_type' => ['required', 'in:creator_story,tutorial,interview,project_breakdown,announcement,resource'],
'status' => ['required', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])],
'tags' => ['nullable', 'array'],
'tags.*' => ['integer', 'exists:story_tags,id'],
]);
$story->update([
'creator_id' => (int) $validated['creator_id'],
'title' => $validated['title'],
'excerpt' => $validated['excerpt'] ?? null,
'cover_image' => $validated['cover_image'] ?? null,
'content' => $validated['content'],
'story_type' => $validated['story_type'],
'reading_time' => max(1, (int) ceil(str_word_count(strip_tags((string) $validated['content'])) / 200)),
'status' => $validated['status'],
'published_at' => $validated['status'] === 'published' ? ($story->published_at ?? now()) : $story->published_at,
'submitted_for_review_at' => $validated['status'] === 'pending_review' ? ($story->submitted_for_review_at ?? now()) : $story->submitted_for_review_at,
]);
$story->tags()->sync($validated['tags'] ?? []);
if (! $wasPublished && $validated['status'] === 'published') {
app(StoryPublicationService::class)->afterPersistence($story, 'published', false);
}
return back()->with('status', 'Story updated.');
}
public function destroy(Story $story): RedirectResponse
{
$story->delete();
return redirect()->route('admin.stories.index')->with('status', 'Story deleted.');
}
public function publish(Story $story): RedirectResponse
{
app(StoryPublicationService::class)->publish($story, 'published', [
'published_at' => $story->published_at ?? now(),
'reviewed_at' => now(),
]);
return back()->with('status', 'Story published.');
}
public function show(Story $story): View
{
return view('admin.stories.show', [
'story' => $story->load(['creator', 'tags']),
]);
}
public function approve(Request $request, Story $story): RedirectResponse
{
app(StoryPublicationService::class)->publish($story, 'approved', [
'published_at' => $story->published_at ?? now(),
'reviewed_at' => now(),
'reviewed_by_id' => (int) $request->user()->id,
'rejected_reason' => null,
]);
return back()->with('status', 'Story approved and published.');
}
public function reject(Request $request, Story $story): RedirectResponse
{
$validated = $request->validate([
'reason' => ['required', 'string', 'max:1000'],
]);
$story->update([
'status' => 'rejected',
'reviewed_at' => now(),
'reviewed_by_id' => (int) $request->user()->id,
'rejected_reason' => $validated['reason'],
]);
$story->creator?->notify(new StoryStatusNotification($story, 'rejected', $validated['reason']));
return back()->with('status', 'Story rejected and creator notified.');
}
public function moderateComments(): View
{
return view('admin.stories.comments-moderation');
}
private function uniqueSlug(string $title): string
{
$base = Str::slug($title);
$slug = $base;
$n = 2;
while (Story::query()->where('slug', $slug)->exists()) {
$slug = $base . '-' . $n;
$n++;
}
return $slug;
}
}

View File

@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\Analytics\TagInteractionReportService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
final class TagInteractionReportController extends Controller
{
public function __construct(private readonly TagInteractionReportService $reportService) {}
public function index(Request $request): View
{
$validated = $request->validate([
'from' => ['nullable', 'date_format:Y-m-d'],
'to' => ['nullable', 'date_format:Y-m-d'],
'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
$from = (string) ($validated['from'] ?? now()->subDays(13)->toDateString());
$to = (string) ($validated['to'] ?? now()->toDateString());
$limit = (int) ($validated['limit'] ?? 15);
abort_if($from > $to, 422, 'Invalid date range.');
$report = $this->reportService->buildReport($from, $to, $limit);
return view('admin.reports.tags', [
'filters' => [
'from' => $from,
'to' => $to,
'limit' => $limit,
],
'overview' => $report['overview'],
'dailyClicks' => $report['daily_clicks'],
'bySurface' => $report['by_surface'],
'topTags' => $report['top_tags'],
'topQueries' => $report['top_queries'],
'topTransitions' => $report['top_transitions'],
'latestAggregatedDate' => $report['latest_aggregated_date'],
]);
}
}

View File

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

View File

@@ -2,12 +2,18 @@
namespace App\Http\Controllers\Api\Messaging; namespace App\Http\Controllers\Api\Messaging;
use App\Events\ConversationUpdated;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Messaging\ManageConversationParticipantRequest;
use App\Http\Requests\Messaging\RenameConversationRequest;
use App\Http\Requests\Messaging\StoreConversationRequest;
use App\Models\Conversation; use App\Models\Conversation;
use App\Models\ConversationParticipant; use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User; use App\Models\User;
use App\Services\Messaging\MessageNotificationService; 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\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@@ -16,6 +22,13 @@ use Illuminate\Support\Facades\Schema;
class ConversationController extends Controller 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 ───────────────────────────────────── // ── GET /api/messages/conversations ─────────────────────────────────────
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
@@ -26,24 +39,14 @@ class ConversationController extends Controller
$cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion); $cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion);
$conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) { $conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) {
return Conversation::query() $query = Conversation::query()
->select('conversations.*') ->select('conversations.*')
->join('conversation_participants as cp_me', function ($join) use ($user) { ->join('conversation_participants as cp_me', function ($join) use ($user) {
$join->on('cp_me.conversation_id', '=', 'conversations.id') $join->on('cp_me.conversation_id', '=', 'conversations.id')
->where('cp_me.user_id', '=', $user->id) ->where('cp_me.user_id', '=', $user->id)
->whereNull('cp_me.left_at'); ->whereNull('cp_me.left_at');
}) })
->addSelect([ ->where('conversations.is_active', true)
'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_at')
->orWhereColumn('messages.created_at', '>', 'cp_me.last_read_at');
}),
])
->with([ ->with([
'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']), 'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']),
'latestMessage.sender:id,username', 'latestMessage.sender:id,username',
@@ -51,8 +54,11 @@ class ConversationController extends Controller
->orderByDesc('cp_me.is_pinned') ->orderByDesc('cp_me.is_pinned')
->orderByDesc('cp_me.pinned_at') ->orderByDesc('cp_me.pinned_at')
->orderByDesc('last_message_at') ->orderByDesc('last_message_at')
->orderByDesc('conversations.id') ->orderByDesc('conversations.id');
->paginate(20, ['conversations.*'], 'page', $page);
$this->unreadCounters->applyUnreadCountSelect($query, $user, 'cp_me');
return $query->paginate(20, ['conversations.*'], 'page', $page);
}); });
$conversations->through(function ($conv) use ($user) { $conversations->through(function ($conv) use ($user) {
@@ -61,7 +67,12 @@ class ConversationController extends Controller
return $conv; return $conv;
}); });
return response()->json($conversations); return response()->json([
...$conversations->toArray(),
'summary' => [
'unread_total' => $this->unreadCounters->totalUnreadForUser($user),
],
]);
} }
// ── GET /api/messages/conversation/{id} ───────────────────────────────── // ── GET /api/messages/conversation/{id} ─────────────────────────────────
@@ -80,18 +91,10 @@ class ConversationController extends Controller
// ── POST /api/messages/conversation ───────────────────────────────────── // ── POST /api/messages/conversation ─────────────────────────────────────
public function store(Request $request): JsonResponse public function store(StoreConversationRequest $request): JsonResponse
{ {
$user = $request->user(); $user = $request->user();
$data = $request->validated();
$data = $request->validate([
'type' => 'required|in:direct,group',
'recipient_id' => 'required_if:type,direct|integer|exists:users,id',
'participant_ids' => 'required_if:type,group|array|min:2',
'participant_ids.*'=> 'integer|exists:users,id',
'title' => 'required_if:type,group|nullable|string|max:120',
'body' => 'required|string|max:5000',
]);
if ($data['type'] === 'direct') { if ($data['type'] === 'direct') {
return $this->createDirect($request, $user, $data); return $this->createDirect($request, $user, $data);
@@ -104,20 +107,29 @@ class ConversationController extends Controller
public function markRead(Request $request, int $id): JsonResponse public function markRead(Request $request, int $id): JsonResponse
{ {
$participant = $this->participantRecord($request, $id); $conversation = $this->findAuthorized($request, $id);
$participant->update(['last_read_at' => now()]); $participant = $this->conversationReads->markConversationRead(
$this->touchConversationCachesForUsers([$request->user()->id]); $conversation,
$request->user(),
$request->integer('message_id') ?: null,
);
return response()->json(['ok' => true]); return response()->json([
'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()),
]);
} }
// ── POST /api/messages/{conversation_id}/archive ───────────────────────── // ── POST /api/messages/{conversation_id}/archive ─────────────────────────
public function archive(Request $request, int $id): JsonResponse public function archive(Request $request, int $id): JsonResponse
{ {
$conversation = $this->findAuthorized($request, $id);
$participant = $this->participantRecord($request, $id); $participant = $this->participantRecord($request, $id);
$participant->update(['is_archived' => ! $participant->is_archived]); $participant->update(['is_archived' => ! $participant->is_archived]);
$this->touchConversationCachesForUsers([$request->user()->id]); $this->broadcastConversationUpdate($conversation, 'conversation.archived');
return response()->json(['is_archived' => $participant->is_archived]); return response()->json(['is_archived' => $participant->is_archived]);
} }
@@ -126,27 +138,30 @@ class ConversationController extends Controller
public function mute(Request $request, int $id): JsonResponse public function mute(Request $request, int $id): JsonResponse
{ {
$conversation = $this->findAuthorized($request, $id);
$participant = $this->participantRecord($request, $id); $participant = $this->participantRecord($request, $id);
$participant->update(['is_muted' => ! $participant->is_muted]); $participant->update(['is_muted' => ! $participant->is_muted]);
$this->touchConversationCachesForUsers([$request->user()->id]); $this->broadcastConversationUpdate($conversation, 'conversation.muted');
return response()->json(['is_muted' => $participant->is_muted]); return response()->json(['is_muted' => $participant->is_muted]);
} }
public function pin(Request $request, int $id): JsonResponse public function pin(Request $request, int $id): JsonResponse
{ {
$conversation = $this->findAuthorized($request, $id);
$participant = $this->participantRecord($request, $id); $participant = $this->participantRecord($request, $id);
$participant->update(['is_pinned' => true, 'pinned_at' => now()]); $participant->update(['is_pinned' => true, 'pinned_at' => now()]);
$this->touchConversationCachesForUsers([$request->user()->id]); $this->broadcastConversationUpdate($conversation, 'conversation.pinned');
return response()->json(['is_pinned' => true]); return response()->json(['is_pinned' => true]);
} }
public function unpin(Request $request, int $id): JsonResponse public function unpin(Request $request, int $id): JsonResponse
{ {
$conversation = $this->findAuthorized($request, $id);
$participant = $this->participantRecord($request, $id); $participant = $this->participantRecord($request, $id);
$participant->update(['is_pinned' => false, 'pinned_at' => null]); $participant->update(['is_pinned' => false, 'pinned_at' => null]);
$this->touchConversationCachesForUsers([$request->user()->id]); $this->broadcastConversationUpdate($conversation, 'conversation.unpinned');
return response()->json(['is_pinned' => false]); return response()->json(['is_pinned' => false]);
} }
@@ -182,14 +197,15 @@ class ConversationController extends Controller
} }
$participant->update(['left_at' => now()]); $participant->update(['left_at' => now()]);
$this->touchConversationCachesForUsers($participantUserIds); $this->conversationState->touchConversationCachesForUsers($participantUserIds);
$this->broadcastConversationUpdate($conv, 'conversation.left', $participantUserIds);
return response()->json(['ok' => true]); return response()->json(['ok' => true]);
} }
// ── POST /api/messages/{conversation_id}/add-user ──────────────────────── // ── POST /api/messages/{conversation_id}/add-user ────────────────────────
public function addUser(Request $request, int $id): JsonResponse public function addUser(ManageConversationParticipantRequest $request, int $id): JsonResponse
{ {
$conv = $this->findAuthorized($request, $id); $conv = $this->findAuthorized($request, $id);
$this->requireAdmin($request, $id); $this->requireAdmin($request, $id);
@@ -198,9 +214,7 @@ class ConversationController extends Controller
->pluck('user_id') ->pluck('user_id')
->all(); ->all();
$data = $request->validate([ $data = $request->validated();
'user_id' => 'required|integer|exists:users,id',
]);
$existing = ConversationParticipant::where('conversation_id', $id) $existing = ConversationParticipant::where('conversation_id', $id)
->where('user_id', $data['user_id']) ->where('user_id', $data['user_id'])
@@ -220,20 +234,18 @@ class ConversationController extends Controller
} }
$participantUserIds[] = (int) $data['user_id']; $participantUserIds[] = (int) $data['user_id'];
$this->touchConversationCachesForUsers($participantUserIds); $this->conversationState->touchConversationCachesForUsers($participantUserIds);
$this->broadcastConversationUpdate($conv, 'conversation.participant_added', $participantUserIds);
return response()->json(['ok' => true]); return response()->json(['ok' => true]);
} }
// ── DELETE /api/messages/{conversation_id}/remove-user ─────────────────── // ── DELETE /api/messages/{conversation_id}/remove-user ───────────────────
public function removeUser(Request $request, int $id): JsonResponse public function removeUser(ManageConversationParticipantRequest $request, int $id): JsonResponse
{ {
$this->requireAdmin($request, $id); $this->requireAdmin($request, $id);
$data = $request->validated();
$data = $request->validate([
'user_id' => 'required|integer',
]);
// Cannot remove the conversation creator // Cannot remove the conversation creator
$conv = Conversation::findOrFail($id); $conv = Conversation::findOrFail($id);
@@ -263,26 +275,28 @@ class ConversationController extends Controller
->whereNull('left_at') ->whereNull('left_at')
->update(['left_at' => now()]); ->update(['left_at' => now()]);
$this->touchConversationCachesForUsers($participantUserIds); $this->conversationState->touchConversationCachesForUsers($participantUserIds);
$this->broadcastConversationUpdate($conv, 'conversation.participant_removed', $participantUserIds);
return response()->json(['ok' => true]); return response()->json(['ok' => true]);
} }
// ── POST /api/messages/{conversation_id}/rename ────────────────────────── // ── POST /api/messages/{conversation_id}/rename ──────────────────────────
public function rename(Request $request, int $id): JsonResponse public function rename(RenameConversationRequest $request, int $id): JsonResponse
{ {
$conv = $this->findAuthorized($request, $id); $conv = $this->findAuthorized($request, $id);
abort_unless($conv->isGroup(), 422, 'Only group conversations can be renamed.'); abort_unless($conv->isGroup(), 422, 'Only group conversations can be renamed.');
$this->requireAdmin($request, $id); $this->requireAdmin($request, $id);
$data = $request->validate(['title' => 'required|string|max:120']); $data = $request->validated();
$conv->update(['title' => $data['title']]); $conv->update(['title' => $data['title']]);
$participantUserIds = ConversationParticipant::where('conversation_id', $id) $participantUserIds = ConversationParticipant::where('conversation_id', $id)
->whereNull('left_at') ->whereNull('left_at')
->pluck('user_id') ->pluck('user_id')
->all(); ->all();
$this->touchConversationCachesForUsers($participantUserIds); $this->conversationState->touchConversationCachesForUsers($participantUserIds);
$this->broadcastConversationUpdate($conv, 'conversation.renamed', $participantUserIds);
return response()->json(['title' => $conv->title]); return response()->json(['title' => $conv->title]);
} }
@@ -307,8 +321,10 @@ class ConversationController extends Controller
if (! $conv) { if (! $conv) {
$conv = DB::transaction(function () use ($user, $recipient) { $conv = DB::transaction(function () use ($user, $recipient) {
$conv = Conversation::create([ $conv = Conversation::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'type' => 'direct', 'type' => 'direct',
'created_by' => $user->id, 'created_by' => $user->id,
'is_active' => true,
]); ]);
ConversationParticipant::insert([ ConversationParticipant::insert([
@@ -320,17 +336,12 @@ class ConversationController extends Controller
}); });
} }
// Insert first / next message $this->sendMessage->execute($conv, $user, [
$message = $conv->messages()->create([
'sender_id' => $user->id,
'body' => $data['body'], 'body' => $data['body'],
'client_temp_id' => $data['client_temp_id'] ?? null,
]); ]);
$conv->update(['last_message_at' => $message->created_at]); return response()->json($conv->fresh()->load('allParticipants.user:id,username'), 201);
app(MessageNotificationService::class)->notifyNewMessage($conv, $message, $user);
$this->touchConversationCachesForUsers([$user->id, $recipient->id]);
return response()->json($conv->load('allParticipants.user:id,username'), 201);
} }
private function createGroup(Request $request, User $user, array $data): JsonResponse private function createGroup(Request $request, User $user, array $data): JsonResponse
@@ -339,9 +350,11 @@ class ConversationController extends Controller
$conv = DB::transaction(function () use ($user, $data, $participantIds) { $conv = DB::transaction(function () use ($user, $data, $participantIds) {
$conv = Conversation::create([ $conv = Conversation::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'type' => 'group', 'type' => 'group',
'title' => $data['title'], 'title' => $data['title'],
'created_by' => $user->id, 'created_by' => $user->id,
'is_active' => true,
]); ]);
$rows = array_map(fn ($uid) => [ $rows = array_map(fn ($uid) => [
@@ -353,27 +366,21 @@ class ConversationController extends Controller
ConversationParticipant::insert($rows); ConversationParticipant::insert($rows);
$message = $conv->messages()->create([ return $conv;
'sender_id' => $user->id,
'body' => $data['body'],
]);
$conv->update(['last_message_at' => $message->created_at]);
return [$conv, $message];
}); });
[$conversation, $message] = $conv; $this->sendMessage->execute($conv, $user, [
app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $user); 'body' => $data['body'],
$this->touchConversationCachesForUsers($participantIds); 'client_temp_id' => $data['client_temp_id'] ?? null,
]);
return response()->json($conversation->load('allParticipants.user:id,username'), 201); return response()->json($conv->fresh()->load('allParticipants.user:id,username'), 201);
} }
private function findAuthorized(Request $request, int $id): Conversation private function findAuthorized(Request $request, int $id): Conversation
{ {
$conv = Conversation::findOrFail($id); $conv = Conversation::findOrFail($id);
$this->assertParticipant($request, $id); $this->authorize('view', $conv);
return $conv; return $conv;
} }
@@ -399,28 +406,13 @@ class ConversationController extends Controller
private function requireAdmin(Request $request, int $id): void private function requireAdmin(Request $request, int $id): void
{ {
abort_unless( $conversation = Conversation::findOrFail($id);
ConversationParticipant::where('conversation_id', $id) $this->authorize('manageParticipants', $conversation);
->where('user_id', $request->user()->id)
->where('role', 'admin')
->whereNull('left_at')
->exists(),
403,
'Only admins can perform this action.'
);
} }
private function touchConversationCachesForUsers(array $userIds): void private function touchConversationCachesForUsers(array $userIds): void
{ {
foreach (array_unique($userIds) as $userId) { $this->conversationState->touchConversationCachesForUsers($userIds);
if (! $userId) {
continue;
}
$versionKey = $this->cacheVersionKey((int) $userId);
Cache::add($versionKey, 1, now()->addDay());
Cache::increment($versionKey);
}
} }
private function cacheVersionKey(int $userId): string private function cacheVersionKey(int $userId): string
@@ -433,6 +425,16 @@ class ConversationController extends Controller
return "messages:conversations:user:{$userId}:page:{$page}:v:{$version}"; return "messages:conversations:user:{$userId}:page:{$page}:v:{$version}";
} }
private function broadcastConversationUpdate(Conversation $conversation, string $reason, ?array $participantIds = null): void
{
$participantIds ??= $this->conversationState->activeParticipantIds($conversation);
$this->conversationState->touchConversationCachesForUsers($participantIds);
foreach ($participantIds as $participantId) {
event(new ConversationUpdated((int) $participantId, $conversation, $reason));
}
}
private function assertNotBlockedBetween(User $sender, User $recipient): void private function assertNotBlockedBetween(User $sender, User $recipient): void
{ {
if (! Schema::hasTable('user_blocks')) { if (! Schema::hasTable('user_blocks')) {

View File

@@ -2,31 +2,53 @@
namespace App\Http\Controllers\Api\Messaging; namespace App\Http\Controllers\Api\Messaging;
use App\Events\MessageSent; use App\Events\ConversationUpdated;
use App\Events\MessageDeleted;
use App\Events\MessageUpdated;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Messaging\StoreMessageRequest;
use App\Http\Requests\Messaging\ToggleMessageReactionRequest;
use App\Http\Requests\Messaging\UpdateMessageRequest;
use App\Models\Conversation; use App\Models\Conversation;
use App\Models\ConversationParticipant; use App\Models\ConversationParticipant;
use App\Models\Message; use App\Models\Message;
use App\Models\MessageAttachment;
use App\Models\MessageReaction; use App\Models\MessageReaction;
use App\Services\Messaging\ConversationDeltaService;
use App\Services\Messaging\ConversationStateService;
use App\Services\Messaging\MessagingPayloadFactory;
use App\Services\Messaging\MessageSearchIndexer; use App\Services\Messaging\MessageSearchIndexer;
use App\Services\Messaging\MessageNotificationService; use App\Services\Messaging\SendMessageAction;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
class MessageController extends Controller class MessageController extends Controller
{ {
private const PAGE_SIZE = 30; private const PAGE_SIZE = 30;
public function __construct(
private readonly ConversationDeltaService $conversationDelta,
private readonly ConversationStateService $conversationState,
private readonly MessagingPayloadFactory $payloadFactory,
private readonly SendMessageAction $sendMessage,
) {}
// ── GET /api/messages/{conversation_id} ────────────────────────────────── // ── GET /api/messages/{conversation_id} ──────────────────────────────────
public function index(Request $request, int $conversationId): JsonResponse public function index(Request $request, int $conversationId): JsonResponse
{ {
$this->assertParticipant($request, $conversationId); $conversation = $this->findConversationOrFail($conversationId);
$cursor = $request->integer('cursor'); $cursor = $request->integer('cursor') ?: $request->integer('before_id');
$afterId = $request->integer('after_id');
if ($afterId) {
$messages = $this->conversationDelta->messagesAfter($conversation, $request->user(), $afterId);
return response()->json([
'data' => $messages,
'next_cursor' => null,
]);
}
$query = Message::withTrashed() $query = Message::withTrashed()
->where('conversation_id', $conversationId) ->where('conversation_id', $conversationId)
@@ -44,65 +66,45 @@ class MessageController extends Controller
$nextCursor = $hasMore && $messages->isNotEmpty() ? (int) $messages->first()->id : null; $nextCursor = $hasMore && $messages->isNotEmpty() ? (int) $messages->first()->id : null;
return response()->json([ return response()->json([
'data' => $messages, 'data' => $messages->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $request->user()->id))->values(),
'next_cursor' => $nextCursor, 'next_cursor' => $nextCursor,
]); ]);
} }
public function delta(Request $request, int $conversationId): JsonResponse
{
$conversation = $this->findConversationOrFail($conversationId);
$afterMessageId = max(0, (int) $request->integer('after_message_id'));
abort_if($afterMessageId < 1, 422, 'after_message_id is required.');
return response()->json([
'data' => $this->conversationDelta->messagesAfter($conversation, $request->user(), $afterMessageId),
]);
}
// ── POST /api/messages/{conversation_id} ───────────────────────────────── // ── POST /api/messages/{conversation_id} ─────────────────────────────────
public function store(Request $request, int $conversationId): JsonResponse public function store(StoreMessageRequest $request, int $conversationId): JsonResponse
{ {
$this->assertParticipant($request, $conversationId); $conversation = $this->findConversationOrFail($conversationId);
$data = $request->validated();
$data = $request->validate([ $data['attachments'] = $request->file('attachments', []);
'body' => 'nullable|string|max:5000',
'attachments' => 'sometimes|array|max:5',
'attachments.*' => 'file|max:25600',
]);
$body = trim((string) ($data['body'] ?? '')); $body = trim((string) ($data['body'] ?? ''));
$files = $request->file('attachments', []); abort_if($body === '' && empty($data['attachments']), 422, 'Message body or attachment is required.');
abort_if($body === '' && empty($files), 422, 'Message body or attachment is required.');
$message = Message::create([ $message = $this->sendMessage->execute($conversation, $request->user(), $data);
'conversation_id' => $conversationId,
'sender_id' => $request->user()->id,
'body' => $body,
]);
foreach ($files as $file) { return response()->json($this->payloadFactory->message($message, (int) $request->user()->id), 201);
if ($file instanceof UploadedFile) {
$this->storeAttachment($file, $message, (int) $request->user()->id);
}
}
Conversation::where('id', $conversationId)
->update(['last_message_at' => $message->created_at]);
$conversation = Conversation::findOrFail($conversationId);
app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $request->user());
app(MessageSearchIndexer::class)->indexMessage($message);
event(new MessageSent($conversationId, $message->id, $request->user()->id));
$participantUserIds = ConversationParticipant::where('conversation_id', $conversationId)
->whereNull('left_at')
->pluck('user_id')
->all();
$this->touchConversationCachesForUsers($participantUserIds);
$message->load(['sender:id,username', 'attachments']);
return response()->json($message, 201);
} }
// ── POST /api/messages/{conversation_id}/react ─────────────────────────── // ── POST /api/messages/{conversation_id}/react ───────────────────────────
public function react(Request $request, int $conversationId, int $messageId): JsonResponse public function react(ToggleMessageReactionRequest $request, int $conversationId, int $messageId): JsonResponse
{ {
$this->assertParticipant($request, $conversationId); $this->findConversationOrFail($conversationId);
$data = $request->validated();
$data = $request->validate(['reaction' => 'required|string|max:32']);
$this->assertAllowedReaction($data['reaction']); $this->assertAllowedReaction($data['reaction']);
$existing = MessageReaction::where([ $existing = MessageReaction::where([
@@ -126,11 +128,10 @@ class MessageController extends Controller
// ── DELETE /api/messages/{conversation_id}/react ───────────────────────── // ── DELETE /api/messages/{conversation_id}/react ─────────────────────────
public function unreact(Request $request, int $conversationId, int $messageId): JsonResponse public function unreact(ToggleMessageReactionRequest $request, int $conversationId, int $messageId): JsonResponse
{ {
$this->assertParticipant($request, $conversationId); $this->findConversationOrFail($conversationId);
$data = $request->validated();
$data = $request->validate(['reaction' => 'required|string|max:32']);
$this->assertAllowedReaction($data['reaction']); $this->assertAllowedReaction($data['reaction']);
MessageReaction::where([ MessageReaction::where([
@@ -142,12 +143,11 @@ class MessageController extends Controller
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id)); return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
} }
public function reactByMessage(Request $request, int $messageId): JsonResponse public function reactByMessage(ToggleMessageReactionRequest $request, int $messageId): JsonResponse
{ {
$message = Message::query()->findOrFail($messageId); $message = Message::query()->findOrFail($messageId);
$this->assertParticipant($request, (int) $message->conversation_id); $this->findConversationOrFail((int) $message->conversation_id);
$data = $request->validated();
$data = $request->validate(['reaction' => 'required|string|max:32']);
$this->assertAllowedReaction($data['reaction']); $this->assertAllowedReaction($data['reaction']);
$existing = MessageReaction::where([ $existing = MessageReaction::where([
@@ -169,12 +169,11 @@ class MessageController extends Controller
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id)); return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
} }
public function unreactByMessage(Request $request, int $messageId): JsonResponse public function unreactByMessage(ToggleMessageReactionRequest $request, int $messageId): JsonResponse
{ {
$message = Message::query()->findOrFail($messageId); $message = Message::query()->findOrFail($messageId);
$this->assertParticipant($request, (int) $message->conversation_id); $this->findConversationOrFail((int) $message->conversation_id);
$data = $request->validated();
$data = $request->validate(['reaction' => 'required|string|max:32']);
$this->assertAllowedReaction($data['reaction']); $this->assertAllowedReaction($data['reaction']);
MessageReaction::where([ MessageReaction::where([
@@ -188,19 +187,15 @@ class MessageController extends Controller
// ── PATCH /api/messages/message/{messageId} ─────────────────────────────── // ── PATCH /api/messages/message/{messageId} ───────────────────────────────
public function update(Request $request, int $messageId): JsonResponse public function update(UpdateMessageRequest $request, int $messageId): JsonResponse
{ {
$message = Message::findOrFail($messageId); $message = Message::findOrFail($messageId);
abort_unless( $this->authorize('update', $message);
$message->sender_id === $request->user()->id,
403,
'You may only edit your own messages.'
);
abort_if($message->deleted_at !== null, 422, 'Cannot edit a deleted message.'); abort_if($message->deleted_at !== null, 422, 'Cannot edit a deleted message.');
$data = $request->validate(['body' => 'required|string|max:5000']); $data = $request->validated();
$message->update([ $message->update([
'body' => $data['body'], 'body' => $data['body'],
@@ -208,13 +203,21 @@ class MessageController extends Controller
]); ]);
app(MessageSearchIndexer::class)->updateMessage($message); app(MessageSearchIndexer::class)->updateMessage($message);
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id) $participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id);
->whereNull('left_at') $this->conversationState->touchConversationCachesForUsers($participantUserIds);
->pluck('user_id')
->all();
$this->touchConversationCachesForUsers($participantUserIds);
return response()->json($message->fresh()); DB::afterCommit(function () use ($message, $participantUserIds): void {
event(new MessageUpdated($message->fresh(['sender:id,username,name', 'attachments', 'reactions'])));
$conversation = Conversation::find($message->conversation_id);
if ($conversation) {
foreach ($participantUserIds as $participantId) {
event(new ConversationUpdated((int) $participantId, $conversation, 'message.updated'));
}
}
});
return response()->json($this->payloadFactory->message($message->fresh(['sender:id,username,name', 'attachments', 'reactions']), (int) $request->user()->id));
} }
// ── DELETE /api/messages/message/{messageId} ────────────────────────────── // ── DELETE /api/messages/message/{messageId} ──────────────────────────────
@@ -223,19 +226,24 @@ class MessageController extends Controller
{ {
$message = Message::findOrFail($messageId); $message = Message::findOrFail($messageId);
abort_unless( $this->authorize('delete', $message);
$message->sender_id === $request->user()->id || $request->user()->isAdmin(),
403,
'You may only delete your own messages.'
);
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id) $participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id);
->whereNull('left_at')
->pluck('user_id')
->all();
app(MessageSearchIndexer::class)->deleteMessage($message); app(MessageSearchIndexer::class)->deleteMessage($message);
$message->delete(); $message->delete();
$this->touchConversationCachesForUsers($participantUserIds); $this->conversationState->touchConversationCachesForUsers($participantUserIds);
DB::afterCommit(function () use ($message, $participantUserIds): void {
$message->refresh();
event(new MessageDeleted($message));
$conversation = Conversation::find($message->conversation_id);
if ($conversation) {
foreach ($participantUserIds as $participantId) {
event(new ConversationUpdated((int) $participantId, $conversation, 'message.deleted'));
}
}
});
return response()->json(['ok' => true]); return response()->json(['ok' => true]);
} }
@@ -256,15 +264,7 @@ class MessageController extends Controller
private function touchConversationCachesForUsers(array $userIds): void private function touchConversationCachesForUsers(array $userIds): void
{ {
foreach (array_unique($userIds) as $userId) { $this->conversationState->touchConversationCachesForUsers($userIds);
if (! $userId) {
continue;
}
$versionKey = "messages:conversations:version:{$userId}";
Cache::add($versionKey, 1, now()->addDay());
Cache::increment($versionKey);
}
} }
private function assertAllowedReaction(string $reaction): void private function assertAllowedReaction(string $reaction): void
@@ -298,54 +298,11 @@ class MessageController extends Controller
return $summary; return $summary;
} }
private function storeAttachment(UploadedFile $file, Message $message, int $userId): void private function findConversationOrFail(int $conversationId): Conversation
{ {
$mime = (string) $file->getMimeType(); $conversation = Conversation::query()->findOrFail($conversationId);
$finfoMime = (string) finfo_file(finfo_open(FILEINFO_MIME_TYPE), $file->getPathname()); $this->authorize('view', $conversation);
$detectedMime = $finfoMime !== '' ? $finfoMime : $mime;
$allowedImage = (array) config('messaging.attachments.allowed_image_mimes', []); return $conversation;
$allowedFile = (array) config('messaging.attachments.allowed_file_mimes', []);
$type = in_array($detectedMime, $allowedImage, true) ? 'image' : 'file';
$allowed = $type === 'image' ? $allowedImage : $allowedFile;
abort_unless(in_array($detectedMime, $allowed, true), 422, 'Unsupported attachment type.');
$maxBytes = $type === 'image'
? ((int) config('messaging.attachments.max_image_kb', 10240) * 1024)
: ((int) config('messaging.attachments.max_file_kb', 25600) * 1024);
abort_if($file->getSize() > $maxBytes, 422, 'Attachment exceeds allowed size.');
$year = now()->format('Y');
$month = now()->format('m');
$ext = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: 'bin');
$path = "messages/{$message->conversation_id}/{$year}/{$month}/" . uniqid('att_', true) . ".{$ext}";
$diskName = (string) config('messaging.attachments.disk', 'local');
Storage::disk($diskName)->put($path, file_get_contents($file->getPathname()));
$width = null;
$height = null;
if ($type === 'image') {
$dimensions = @getimagesize($file->getPathname());
$width = isset($dimensions[0]) ? (int) $dimensions[0] : null;
$height = isset($dimensions[1]) ? (int) $dimensions[1] : null;
}
MessageAttachment::query()->create([
'message_id' => $message->id,
'user_id' => $userId,
'type' => $type,
'mime' => $detectedMime,
'size_bytes' => (int) $file->getSize(),
'width' => $width,
'height' => $height,
'sha256' => hash_file('sha256', $file->getPathname()),
'original_name' => substr((string) $file->getClientOriginalName(), 0, 255),
'storage_path' => $path,
'created_at' => now(),
]);
} }
} }

View File

@@ -71,18 +71,12 @@ class MessageSearchController extends Controller
$hits = collect($result->getHits() ?? []); $hits = collect($result->getHits() ?? []);
$estimated = (int) ($result->getEstimatedTotalHits() ?? $hits->count()); $estimated = (int) ($result->getEstimatedTotalHits() ?? $hits->count());
} catch (\Throwable) {
$query = Message::query()
->select('id')
->whereNull('deleted_at')
->whereIn('conversation_id', $allowedConversationIds)
->when($conversationId !== null, fn ($q) => $q->where('conversation_id', $conversationId))
->where('body', 'like', '%' . (string) $data['q'] . '%')
->orderByDesc('created_at')
->orderByDesc('id');
$estimated = (clone $query)->count(); if ($hits->isEmpty()) {
$hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]); [$hits, $estimated] = $this->fallbackHits($allowedConversationIds, $conversationId, (string) $data['q'], $offset, $limit);
}
} catch (\Throwable) {
[$hits, $estimated] = $this->fallbackHits($allowedConversationIds, $conversationId, (string) $data['q'], $offset, $limit);
} }
$messageIds = $hits->pluck('id')->map(fn ($id) => (int) $id)->all(); $messageIds = $hits->pluck('id')->map(fn ($id) => (int) $id)->all();
@@ -122,6 +116,23 @@ class MessageSearchController extends Controller
]); ]);
} }
private function fallbackHits(array $allowedConversationIds, ?int $conversationId, string $queryString, int $offset, int $limit): array
{
$query = Message::query()
->select('id')
->whereNull('deleted_at')
->whereIn('conversation_id', $allowedConversationIds)
->when($conversationId !== null, fn ($builder) => $builder->where('conversation_id', $conversationId))
->where('body', 'like', '%' . $queryString . '%')
->orderByDesc('created_at')
->orderByDesc('id');
$estimated = (clone $query)->count();
$hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]);
return [$hits, $estimated];
}
public function rebuild(Request $request): JsonResponse public function rebuild(Request $request): JsonResponse
{ {
abort_unless($request->user()?->isAdmin(), 403, 'Admin access required.'); abort_unless($request->user()?->isAdmin(), 403, 'Admin access required.');

View File

@@ -16,9 +16,13 @@ class MessagingSettingsController extends Controller
{ {
public function show(Request $request): JsonResponse public function show(Request $request): JsonResponse
{ {
$realtimeReady = (bool) config('messaging.realtime', false)
&& config('broadcasting.default') === 'reverb'
&& filled(config('broadcasting.connections.reverb.key'));
return response()->json([ return response()->json([
'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone', 'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone',
'realtime_enabled' => (bool) config('messaging.realtime', false), 'realtime_enabled' => $realtimeReady,
]); ]);
} }

View File

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

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Messaging;
use App\Events\TypingStarted; use App\Events\TypingStarted;
use App\Events\TypingStopped; use App\Events\TypingStopped;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Conversation;
use App\Models\ConversationParticipant; use App\Models\ConversationParticipant;
use Illuminate\Cache\Repository; use Illuminate\Cache\Repository;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -15,13 +16,13 @@ class TypingController extends Controller
{ {
public function start(Request $request, int $conversationId): JsonResponse public function start(Request $request, int $conversationId): JsonResponse
{ {
$this->assertParticipant($request, $conversationId); $this->findConversationOrFail($conversationId);
$ttl = max(5, (int) config('messaging.typing.ttl_seconds', 8)); $ttl = max(5, (int) config('messaging.typing.ttl_seconds', 8));
$this->store()->put($this->key($conversationId, (int) $request->user()->id), 1, now()->addSeconds($ttl)); $this->store()->put($this->key($conversationId, (int) $request->user()->id), 1, now()->addSeconds($ttl));
if ((bool) config('messaging.realtime', false)) { if ((bool) config('messaging.realtime', false)) {
event(new TypingStarted($conversationId, (int) $request->user()->id)); event(new TypingStarted($conversationId, $request->user()));
} }
return response()->json(['ok' => true]); return response()->json(['ok' => true]);
@@ -29,11 +30,11 @@ class TypingController extends Controller
public function stop(Request $request, int $conversationId): JsonResponse public function stop(Request $request, int $conversationId): JsonResponse
{ {
$this->assertParticipant($request, $conversationId); $this->findConversationOrFail($conversationId);
$this->store()->forget($this->key($conversationId, (int) $request->user()->id)); $this->store()->forget($this->key($conversationId, (int) $request->user()->id));
if ((bool) config('messaging.realtime', false)) { if ((bool) config('messaging.realtime', false)) {
event(new TypingStopped($conversationId, (int) $request->user()->id)); event(new TypingStopped($conversationId, $request->user()));
} }
return response()->json(['ok' => true]); return response()->json(['ok' => true]);
@@ -41,7 +42,7 @@ class TypingController extends Controller
public function index(Request $request, int $conversationId): JsonResponse public function index(Request $request, int $conversationId): JsonResponse
{ {
$this->assertParticipant($request, $conversationId); $this->findConversationOrFail($conversationId);
$userId = (int) $request->user()->id; $userId = (int) $request->user()->id;
$participants = ConversationParticipant::query() $participants = ConversationParticipant::query()
@@ -93,4 +94,12 @@ class TypingController extends Controller
return Cache::store(); return Cache::store();
} }
} }
private function findConversationOrFail(int $conversationId): Conversation
{
$conversation = Conversation::query()->findOrFail($conversationId);
$this->authorize('view', $conversation);
return $conversation;
}
} }

View File

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

View File

@@ -13,35 +13,91 @@ class FollowerController extends Controller
{ {
$user = $request->user(); $user = $request->user();
$perPage = 30; $perPage = 30;
$search = trim((string) $request->query('q', ''));
$sort = (string) $request->query('sort', 'recent');
$relationship = (string) $request->query('relationship', 'all');
$allowedSorts = ['recent', 'oldest', 'name', 'uploads', 'followers'];
$allowedRelationships = ['all', 'following-back', 'not-followed'];
if (! in_array($sort, $allowedSorts, true)) {
$sort = 'recent';
}
if (! in_array($relationship, $allowedRelationships, true)) {
$relationship = 'all';
}
// People who follow $user (user_id = $user being followed) // People who follow $user (user_id = $user being followed)
$followers = DB::table('user_followers as uf') $baseQuery = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.follower_id') ->join('users as u', 'u.id', '=', 'uf.follower_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id') ->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->leftJoin('user_followers as mutual', function ($join) use ($user): void {
$join->on('mutual.user_id', '=', 'uf.follower_id')
->where('mutual.follower_id', '=', $user->id);
})
->where('uf.user_id', $user->id) ->where('uf.user_id', $user->id)
->whereNull('u.deleted_at') ->whereNull('u.deleted_at')
->orderByDesc('uf.created_at') ->when($search !== '', function ($query) use ($search): void {
$query->where(function ($inner) use ($search): void {
$inner->where('u.username', 'like', '%' . $search . '%')
->orWhere('u.name', 'like', '%' . $search . '%');
});
})
->when($relationship === 'following-back', function ($query): void {
$query->whereNotNull('mutual.created_at');
})
->when($relationship === 'not-followed', function ($query): void {
$query->whereNull('mutual.created_at');
});
$summaryBaseQuery = clone $baseQuery;
$followers = $baseQuery
->when($sort === 'recent', fn ($query) => $query->orderByDesc('uf.created_at'))
->when($sort === 'oldest', fn ($query) => $query->orderBy('uf.created_at'))
->when($sort === 'name', fn ($query) => $query->orderByRaw('COALESCE(u.username, u.name) asc'))
->when($sort === 'uploads', fn ($query) => $query->orderByDesc('us.uploads_count')->orderByRaw('COALESCE(u.username, u.name) asc'))
->when($sort === 'followers', fn ($query) => $query->orderByDesc('us.followers_count')->orderByRaw('COALESCE(u.username, u.name) asc'))
->select([ ->select([
'u.id', 'u.username', 'u.name', 'u.id', 'u.username', 'u.name',
'up.avatar_hash', 'up.avatar_hash',
'us.uploads_count', 'us.uploads_count',
'us.followers_count',
'uf.created_at as followed_at', 'uf.created_at as followed_at',
'mutual.created_at as followed_back_at',
]) ])
->paginate($perPage) ->paginate($perPage)
->withQueryString() ->withQueryString()
->through(fn ($row) => (object) [ ->through(fn ($row) => (object) [
'id' => $row->id, 'id' => $row->id,
'name' => $row->name,
'username' => $row->username, 'username' => $row->username,
'uname' => $row->username ?? $row->name, 'uname' => $row->username ?? $row->name,
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50), 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)), 'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'uploads' => $row->uploads_count ?? 0, 'uploads' => $row->uploads_count ?? 0,
'followers_count' => $row->followers_count ?? 0,
'is_following_back' => $row->followed_back_at !== null,
'followed_back_at' => $row->followed_back_at,
'followed_at' => $row->followed_at, 'followed_at' => $row->followed_at,
]); ]);
$summary = [
'total_followers' => (clone $summaryBaseQuery)->count(),
'following_back' => (clone $summaryBaseQuery)->whereNotNull('mutual.created_at')->count(),
'not_followed' => (clone $summaryBaseQuery)->whereNull('mutual.created_at')->count(),
];
return view('dashboard.followers', [ return view('dashboard.followers', [
'followers' => $followers, 'followers' => $followers,
'filters' => [
'q' => $search,
'sort' => $sort,
'relationship' => $relationship,
],
'summary' => $summary,
'page_title' => 'My Followers', 'page_title' => 'My Followers',
]); ]);
} }

View File

@@ -13,21 +13,60 @@ class FollowingController extends Controller
{ {
$user = $request->user(); $user = $request->user();
$perPage = 30; $perPage = 30;
$search = trim((string) $request->query('q', ''));
$sort = (string) $request->query('sort', 'recent');
$relationship = (string) $request->query('relationship', 'all');
$allowedSorts = ['recent', 'oldest', 'name', 'uploads', 'followers'];
$allowedRelationships = ['all', 'mutual', 'one-way'];
if (! in_array($sort, $allowedSorts, true)) {
$sort = 'recent';
}
if (! in_array($relationship, $allowedRelationships, true)) {
$relationship = 'all';
}
// People that $user follows (follower_id = $user) // People that $user follows (follower_id = $user)
$following = DB::table('user_followers as uf') $baseQuery = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.user_id') ->join('users as u', 'u.id', '=', 'uf.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id') ->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->leftJoin('user_followers as mutual', function ($join) use ($user): void {
$join->on('mutual.follower_id', '=', 'uf.user_id')
->where('mutual.user_id', '=', $user->id);
})
->where('uf.follower_id', $user->id) ->where('uf.follower_id', $user->id)
->whereNull('u.deleted_at') ->whereNull('u.deleted_at')
->orderByDesc('uf.created_at') ->when($search !== '', function ($query) use ($search): void {
$query->where(function ($inner) use ($search): void {
$inner->where('u.username', 'like', '%' . $search . '%')
->orWhere('u.name', 'like', '%' . $search . '%');
});
})
->when($relationship === 'mutual', function ($query): void {
$query->whereNotNull('mutual.created_at');
})
->when($relationship === 'one-way', function ($query): void {
$query->whereNull('mutual.created_at');
});
$summaryBaseQuery = clone $baseQuery;
$following = $baseQuery
->when($sort === 'recent', fn ($query) => $query->orderByDesc('uf.created_at'))
->when($sort === 'oldest', fn ($query) => $query->orderBy('uf.created_at'))
->when($sort === 'name', fn ($query) => $query->orderByRaw('COALESCE(u.username, u.name) asc'))
->when($sort === 'uploads', fn ($query) => $query->orderByDesc('us.uploads_count')->orderByRaw('COALESCE(u.username, u.name) asc'))
->when($sort === 'followers', fn ($query) => $query->orderByDesc('us.followers_count')->orderByRaw('COALESCE(u.username, u.name) asc'))
->select([ ->select([
'u.id', 'u.username', 'u.name', 'u.id', 'u.username', 'u.name',
'up.avatar_hash', 'up.avatar_hash',
'us.uploads_count', 'us.uploads_count',
'us.followers_count', 'us.followers_count',
'uf.created_at as followed_at', 'uf.created_at as followed_at',
'mutual.created_at as follows_you_at',
]) ])
->paginate($perPage) ->paginate($perPage)
->withQueryString() ->withQueryString()
@@ -40,11 +79,25 @@ class FollowingController extends Controller
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)), 'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'uploads' => $row->uploads_count ?? 0, 'uploads' => $row->uploads_count ?? 0,
'followers_count' => $row->followers_count ?? 0, 'followers_count' => $row->followers_count ?? 0,
'follows_you' => $row->follows_you_at !== null,
'follows_you_at' => $row->follows_you_at,
'followed_at' => $row->followed_at, 'followed_at' => $row->followed_at,
]); ]);
$summary = [
'total_following' => (clone $summaryBaseQuery)->count(),
'mutual' => (clone $summaryBaseQuery)->whereNotNull('mutual.created_at')->count(),
'one_way' => (clone $summaryBaseQuery)->whereNull('mutual.created_at')->count(),
];
return view('dashboard.following', [ return view('dashboard.following', [
'following' => $following, 'following' => $following,
'filters' => [
'q' => $search,
'sort' => $sort,
'relationship' => $relationship,
],
'summary' => $summary,
'page_title' => 'People I Follow', 'page_title' => 'People I Follow',
]); ]);
} }

View File

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

View File

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

View File

@@ -1,21 +0,0 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\StaffApplication;
use Illuminate\Http\Request;
class StaffApplicationAdminController extends Controller
{
public function index(Request $request)
{
$items = StaffApplication::orderBy('created_at', 'desc')->paginate(25);
return view('admin.staff_applications.index', ['items' => $items]);
}
public function show(StaffApplication $staffApplication)
{
return view('admin.staff_applications.show', ['item' => $staffApplication]);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Messaging;
use Illuminate\Foundation\Http\FormRequest;
class ManageConversationParticipantRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'user_id' => 'required|integer|exists:users,id',
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Messaging;
use Illuminate\Foundation\Http\FormRequest;
class RenameConversationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'title' => 'required|string|max:120',
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Messaging;
use Illuminate\Foundation\Http\FormRequest;
class StoreConversationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'type' => 'required|in:direct,group',
'recipient_id' => 'required_if:type,direct|integer|exists:users,id',
'participant_ids' => 'required_if:type,group|array|min:2',
'participant_ids.*' => 'integer|exists:users,id',
'title' => 'required_if:type,group|nullable|string|max:120',
'body' => 'required|string|max:5000',
'client_temp_id' => 'nullable|string|max:120',
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Messaging;
use Illuminate\Foundation\Http\FormRequest;
class StoreMessageRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'body' => 'nullable|string|max:5000',
'attachments' => 'sometimes|array|max:5',
'attachments.*' => 'file|max:25600',
'client_temp_id' => 'nullable|string|max:120',
'reply_to_message_id' => 'nullable|integer|exists:messages,id',
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Messaging;
use Illuminate\Foundation\Http\FormRequest;
class ToggleMessageReactionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'reaction' => 'required|string|max:32',
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Messaging;
use Illuminate\Foundation\Http\FormRequest;
class UpdateMessageRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'body' => 'required|string|max:5000',
];
}
}

View File

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

View File

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

View File

@@ -23,14 +23,18 @@ class Conversation extends Model
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
'uuid',
'type', 'type',
'title', 'title',
'created_by', 'created_by',
'last_message_id',
'last_message_at', 'last_message_at',
'is_active',
]; ];
protected $casts = [ protected $casts = [
'last_message_at' => 'datetime', 'last_message_at' => 'datetime',
'is_active' => 'boolean',
]; ];
// ── Relationships ──────────────────────────────────────────────────────── // ── Relationships ────────────────────────────────────────────────────────
@@ -81,6 +85,7 @@ class Conversation extends Model
{ {
return self::query() return self::query()
->where('type', 'direct') ->where('type', 'direct')
->where('is_active', true)
->whereHas('allParticipants', fn ($q) => $q->where('user_id', $userA)->whereNull('left_at')) ->whereHas('allParticipants', fn ($q) => $q->where('user_id', $userA)->whereNull('left_at'))
->whereHas('allParticipants', fn ($q) => $q->where('user_id', $userB)->whereNull('left_at')) ->whereHas('allParticipants', fn ($q) => $q->where('user_id', $userB)->whereNull('left_at'))
->whereRaw( ->whereRaw(
@@ -108,6 +113,11 @@ class Conversation extends Model
->whereNull('deleted_at') ->whereNull('deleted_at')
->where('sender_id', '!=', $userId); ->where('sender_id', '!=', $userId);
if ($participant->last_read_message_id) {
$query->where('id', '>', $participant->last_read_message_id);
return $query->count();
}
if ($participant->last_read_at) { if ($participant->last_read_at) {
$query->where('created_at', '>', $participant->last_read_at); $query->where('created_at', '>', $participant->last_read_at);
} }

View File

@@ -30,9 +30,11 @@ class ConversationParticipant extends Model
'user_id', 'user_id',
'role', 'role',
'last_read_at', 'last_read_at',
'last_read_message_id',
'is_muted', 'is_muted',
'is_archived', 'is_archived',
'is_pinned', 'is_pinned',
'is_hidden',
'pinned_at', 'pinned_at',
'joined_at', 'joined_at',
'left_at', 'left_at',
@@ -40,9 +42,11 @@ class ConversationParticipant extends Model
protected $casts = [ protected $casts = [
'last_read_at' => 'datetime', 'last_read_at' => 'datetime',
'last_read_message_id' => 'integer',
'is_muted' => 'boolean', 'is_muted' => 'boolean',
'is_archived' => 'boolean', 'is_archived' => 'boolean',
'is_pinned' => 'boolean', 'is_pinned' => 'boolean',
'is_hidden' => 'boolean',
'pinned_at' => 'datetime', 'pinned_at' => 'datetime',
'joined_at' => 'datetime', 'joined_at' => 'datetime',
'left_at' => 'datetime', 'left_at' => 'datetime',

View File

@@ -7,8 +7,11 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
use Laravel\Scout\Searchable; use Laravel\Scout\Searchable;
use App\Models\MessageRead;
/** /**
* @property int $id * @property int $id
* @property int $conversation_id * @property int $conversation_id
@@ -24,16 +27,31 @@ class Message extends Model
use HasFactory, SoftDeletes, Searchable; use HasFactory, SoftDeletes, Searchable;
protected $fillable = [ protected $fillable = [
'uuid',
'client_temp_id',
'conversation_id', 'conversation_id',
'sender_id', 'sender_id',
'message_type',
'body', 'body',
'meta_json',
'reply_to_message_id',
'edited_at', 'edited_at',
]; ];
protected $casts = [ protected $casts = [
'meta_json' => 'array',
'edited_at' => 'datetime', 'edited_at' => 'datetime',
]; ];
protected static function booted(): void
{
static::creating(function (self $message): void {
if (! $message->uuid) {
$message->uuid = (string) Str::uuid();
}
});
}
// ── Relationships ──────────────────────────────────────────────────────── // ── Relationships ────────────────────────────────────────────────────────
public function conversation(): BelongsTo public function conversation(): BelongsTo
@@ -56,9 +74,14 @@ class Message extends Model
return $this->hasMany(MessageAttachment::class); return $this->hasMany(MessageAttachment::class);
} }
public function setBodyAttribute(string $value): void public function reads(): HasMany
{ {
$sanitized = trim(strip_tags($value)); return $this->hasMany(MessageRead::class);
}
public function setBodyAttribute(?string $value): void
{
$sanitized = trim(strip_tags((string) $value));
$this->attributes['body'] = $sanitized; $this->attributes['body'] = $sanitized;
} }

View File

@@ -14,6 +14,7 @@ class MessageAttachment extends Model
protected $fillable = [ protected $fillable = [
'message_id', 'message_id',
'disk',
'user_id', 'user_id',
'type', 'type',
'mime', 'mime',

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MessageRead extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'message_id',
'user_id',
'read_at',
];
protected $casts = [
'read_at' => 'datetime',
];
public function message(): BelongsTo
{
return $this->belongsTo(Message::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Policies;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\User;
class ConversationPolicy
{
public function view(User $user, Conversation $conversation): bool
{
return $this->participantRecord($user, $conversation) !== null
&& (bool) ($conversation->is_active ?? true);
}
public function send(User $user, Conversation $conversation): bool
{
return $this->view($user, $conversation);
}
public function manageParticipants(User $user, Conversation $conversation): bool
{
$participant = $this->participantRecord($user, $conversation);
return $participant !== null && $participant->role === 'admin';
}
public function rename(User $user, Conversation $conversation): bool
{
return $conversation->isGroup() && $this->manageParticipants($user, $conversation);
}
public function joinPresence(User $user, Conversation $conversation): bool
{
return $this->view($user, $conversation);
}
private function participantRecord(User $user, Conversation $conversation): ?ConversationParticipant
{
return ConversationParticipant::query()
->where('conversation_id', $conversation->id)
->where('user_id', $user->id)
->whereNull('left_at')
->first();
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Policies;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User;
class MessagePolicy
{
public function view(User $user, Message $message): bool
{
return ConversationParticipant::query()
->where('conversation_id', $message->conversation_id)
->where('user_id', $user->id)
->whereNull('left_at')
->exists();
}
public function update(User $user, Message $message): bool
{
return $message->sender_id === $user->id && $message->deleted_at === null;
}
public function delete(User $user, Message $message): bool
{
return $message->sender_id === $user->id || $user->isAdmin();
}
}

View File

@@ -290,6 +290,44 @@ class AppServiceProvider extends ServiceProvider
Limit::perMinute(120)->by('messages:react:ip:' . $request->ip()), Limit::perMinute(120)->by('messages:react:ip:' . $request->ip()),
]; ];
}); });
RateLimiter::for('messages-read', function (Request $request): array {
$userId = $request->user()?->id ?? 'guest';
return [
Limit::perMinute(120)->by('messages:read:user:' . $userId),
Limit::perMinute(240)->by('messages:read:ip:' . $request->ip()),
];
});
RateLimiter::for('messages-typing', function (Request $request): array {
$userId = $request->user()?->id ?? 'guest';
$conversationId = (int) $request->route('conversation_id');
return [
Limit::perMinute(90)->by('messages:typing:user:' . $userId . ':conv:' . $conversationId),
Limit::perMinute(180)->by('messages:typing:ip:' . $request->ip()),
];
});
RateLimiter::for('messages-recovery', function (Request $request): array {
$userId = $request->user()?->id ?? 'guest';
$conversationId = (int) $request->route('conversation_id');
return [
Limit::perMinute(30)->by('messages:recovery:user:' . $userId . ':conv:' . $conversationId),
Limit::perMinute(60)->by('messages:recovery:ip:' . $request->ip()),
];
});
RateLimiter::for('messages-presence', function (Request $request): array {
$userId = $request->user()?->id ?? 'guest';
return [
Limit::perMinute(180)->by('messages:presence:user:' . $userId),
Limit::perMinute(300)->by('messages:presence:ip:' . $request->ip()),
];
});
} }
private function configureDownloadRateLimiter(): void private function configureDownloadRateLimiter(): void
@@ -396,8 +434,6 @@ class AppServiceProvider extends ServiceProvider
try { try {
/** @var Menu $menu */ /** @var Menu $menu */
$menu = $this->app->make(Menu::class); $menu = $this->app->make(Menu::class);
$menu->addHeaderItem('Countries', 'fa-solid fa-flag', 'admin.cp.countries.main');
$menu->addItem('Users', 'Countries', 'fa-solid fa-flag', 'admin.cp.countries.main');
} catch (\Throwable) { } catch (\Throwable) {
// Control panel menu registration should never block the app boot. // Control panel menu registration should never block the app boot.
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Services\Messaging;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use Illuminate\Support\Facades\Cache;
class ConversationStateService
{
public function activeParticipantIds(Conversation|int $conversation): array
{
$conversationId = $conversation instanceof Conversation ? $conversation->id : $conversation;
return ConversationParticipant::query()
->where('conversation_id', $conversationId)
->whereNull('left_at')
->pluck('user_id')
->map(fn ($id) => (int) $id)
->all();
}
public function touchConversationCachesForUsers(array $userIds): void
{
foreach (array_unique($userIds) as $userId) {
if (! $userId) {
continue;
}
$versionKey = "messages:conversations:version:{$userId}";
Cache::add($versionKey, 1, now()->addDay());
Cache::increment($versionKey);
}
}
}

View File

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

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Services\Messaging;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\MessageAttachment;
use App\Models\User;
class MessagingPayloadFactory
{
public function message(Message $message, ?int $viewerId = null): array
{
$message->loadMissing([
'sender:id,username,name',
'attachments',
'reactions',
]);
return [
'id' => (int) $message->id,
'uuid' => (string) $message->uuid,
'client_temp_id' => $message->client_temp_id,
'conversation_id' => (int) $message->conversation_id,
'sender_id' => (int) $message->sender_id,
'sender' => $this->userSummary($message->sender),
'message_type' => (string) ($message->message_type ?? 'text'),
'body' => (string) ($message->body ?? ''),
'reply_to_message_id' => $message->reply_to_message_id ? (int) $message->reply_to_message_id : null,
'attachments' => $message->attachments->map(fn (MessageAttachment $attachment) => $this->attachment($attachment))->values()->all(),
'reaction_summary' => $this->reactionSummary($message, $viewerId),
'edited_at' => optional($message->edited_at)?->toIso8601String(),
'deleted_at' => optional($message->deleted_at)?->toIso8601String(),
'created_at' => optional($message->created_at)?->toIso8601String(),
'updated_at' => optional($message->updated_at)?->toIso8601String(),
];
}
public function conversationSummary(Conversation $conversation, int $viewerId): array
{
$conversation->loadMissing([
'allParticipants.user:id,username,name',
'latestMessage.sender:id,username,name',
'latestMessage.attachments',
'latestMessage.reactions',
]);
/** @var ConversationParticipant|null $myParticipant */
$myParticipant = $conversation->allParticipants->firstWhere('user_id', $viewerId);
return [
'id' => (int) $conversation->id,
'uuid' => (string) $conversation->uuid,
'type' => (string) $conversation->type,
'title' => $conversation->title,
'is_active' => (bool) ($conversation->is_active ?? true),
'last_message_at' => optional($conversation->last_message_at)?->toIso8601String(),
'unread_count' => app(UnreadCounterService::class)->unreadCountForConversation($conversation, $viewerId),
'my_participant' => $myParticipant ? $this->participant($myParticipant) : null,
'all_participants' => $conversation->allParticipants
->whereNull('left_at')
->map(fn (ConversationParticipant $participant) => $this->participant($participant))
->values()
->all(),
'latest_message' => $conversation->latestMessage
? $this->message($conversation->latestMessage, $viewerId)
: null,
];
}
public function presenceUser(User $user): array
{
return [
'id' => (int) $user->id,
'username' => (string) $user->username,
'display_name' => (string) ($user->name ?: $user->username),
'avatar_thumb_url' => null,
];
}
public function userSummary(?User $user): array
{
if (! $user) {
return [
'id' => null,
'username' => null,
'display_name' => null,
'avatar_thumb_url' => null,
];
}
return [
'id' => (int) $user->id,
'username' => (string) $user->username,
'display_name' => (string) ($user->name ?: $user->username),
'avatar_thumb_url' => null,
];
}
private function participant(ConversationParticipant $participant): array
{
return [
'id' => (int) $participant->id,
'user_id' => (int) $participant->user_id,
'role' => (string) $participant->role,
'last_read_at' => optional($participant->last_read_at)?->toIso8601String(),
'last_read_message_id' => $participant->last_read_message_id ? (int) $participant->last_read_message_id : null,
'is_muted' => (bool) $participant->is_muted,
'is_archived' => (bool) $participant->is_archived,
'is_pinned' => (bool) $participant->is_pinned,
'is_hidden' => (bool) ($participant->is_hidden ?? false),
'pinned_at' => optional($participant->pinned_at)?->toIso8601String(),
'joined_at' => optional($participant->joined_at)?->toIso8601String(),
'left_at' => optional($participant->left_at)?->toIso8601String(),
'user' => $this->userSummary($participant->user),
];
}
private function attachment(MessageAttachment $attachment): array
{
return [
'id' => (int) $attachment->id,
'disk' => (string) ($attachment->disk ?: config('messaging.attachments.disk', 'local')),
'type' => (string) $attachment->type,
'mime' => (string) $attachment->mime,
'size_bytes' => (int) $attachment->size_bytes,
'width' => $attachment->width ? (int) $attachment->width : null,
'height' => $attachment->height ? (int) $attachment->height : null,
'original_name' => (string) $attachment->original_name,
];
}
private function reactionSummary(Message $message, ?int $viewerId = null): array
{
$counts = [];
$mine = [];
foreach ($message->reactions as $reaction) {
$emoji = (string) $reaction->reaction;
$counts[$emoji] = ($counts[$emoji] ?? 0) + 1;
if ($viewerId !== null && (int) $reaction->user_id === $viewerId) {
$mine[] = $emoji;
}
}
$counts['me'] = array_values(array_unique($mine));
return $counts;
}
}

View File

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

View File

@@ -0,0 +1,126 @@
<?php
namespace App\Services\Messaging;
use App\Events\ConversationUpdated;
use App\Events\MessageCreated;
use App\Models\Conversation;
use App\Models\Message;
use App\Models\MessageAttachment;
use App\Models\User;
use App\Services\Messaging\ConversationStateService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class SendMessageAction
{
public function __construct(
private readonly ConversationStateService $conversationState,
private readonly MessageNotificationService $notifications,
private readonly MessageSearchIndexer $searchIndexer,
) {}
public function execute(Conversation $conversation, User $sender, array $payload): Message
{
$body = trim((string) ($payload['body'] ?? ''));
$files = $payload['attachments'] ?? [];
/** @var Message $message */
$message = DB::transaction(function () use ($conversation, $sender, $payload, $body, $files) {
$message = Message::query()->create([
'conversation_id' => $conversation->id,
'sender_id' => $sender->id,
'client_temp_id' => $payload['client_temp_id'] ?? null,
'message_type' => empty($files) ? 'text' : ($body === '' ? 'attachment' : 'text'),
'body' => $body,
'reply_to_message_id' => $payload['reply_to_message_id'] ?? null,
]);
foreach ($files as $file) {
if ($file instanceof UploadedFile) {
$this->storeAttachment($file, $message, $sender->id);
}
}
$conversation->forceFill([
'last_message_id' => $message->id,
'last_message_at' => $message->created_at,
])->save();
return $message;
});
$participantIds = $this->conversationState->activeParticipantIds($conversation);
$this->conversationState->touchConversationCachesForUsers($participantIds);
DB::afterCommit(function () use ($conversation, $message, $sender, $participantIds): void {
$this->notifications->notifyNewMessage($conversation, $message, $sender);
$this->searchIndexer->indexMessage($message);
event(new MessageCreated($conversation, $message, $sender->id));
foreach ($participantIds as $participantId) {
event(new ConversationUpdated($participantId, $conversation, 'message.created'));
}
});
return $message->fresh(['sender:id,username,name', 'attachments', 'reactions']);
}
private function storeAttachment(UploadedFile $file, Message $message, int $userId): void
{
$mime = (string) $file->getMimeType();
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$finfoMime = $finfo ? (string) finfo_file($finfo, $file->getPathname()) : '';
if ($finfo) {
finfo_close($finfo);
}
$detectedMime = $finfoMime !== '' ? $finfoMime : $mime;
$allowedImage = (array) config('messaging.attachments.allowed_image_mimes', []);
$allowedFile = (array) config('messaging.attachments.allowed_file_mimes', []);
$type = in_array($detectedMime, $allowedImage, true) ? 'image' : 'file';
$allowed = $type === 'image' ? $allowedImage : $allowedFile;
abort_unless(in_array($detectedMime, $allowed, true), 422, 'Unsupported attachment type.');
$maxBytes = $type === 'image'
? ((int) config('messaging.attachments.max_image_kb', 10240) * 1024)
: ((int) config('messaging.attachments.max_file_kb', 25600) * 1024);
abort_if($file->getSize() > $maxBytes, 422, 'Attachment exceeds allowed size.');
$year = now()->format('Y');
$month = now()->format('m');
$ext = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: 'bin');
$path = "messages/{$message->conversation_id}/{$year}/{$month}/" . uniqid('att_', true) . ".{$ext}";
$diskName = (string) config('messaging.attachments.disk', 'local');
Storage::disk($diskName)->put($path, file_get_contents($file->getPathname()));
$width = null;
$height = null;
if ($type === 'image') {
$dimensions = @getimagesize($file->getPathname());
$width = isset($dimensions[0]) ? (int) $dimensions[0] : null;
$height = isset($dimensions[1]) ? (int) $dimensions[1] : null;
}
MessageAttachment::query()->create([
'message_id' => $message->id,
'disk' => $diskName,
'user_id' => $userId,
'type' => $type,
'mime' => $detectedMime,
'size_bytes' => (int) $file->getSize(),
'width' => $width,
'height' => $height,
'sha256' => hash_file('sha256', $file->getPathname()),
'original_name' => substr((string) $file->getClientOriginalName(), 0, 255),
'storage_path' => $path,
'created_at' => now(),
]);
}
}

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ return Application::configure(basePath: dirname(__DIR__))
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php', api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {

View File

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

View File

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

1145
composer.lock generated

File diff suppressed because it is too large Load Diff

82
config/broadcasting.php Normal file
View File

@@ -0,0 +1,82 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
*/
'default' => env('BROADCAST_CONNECTION', 'null'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over WebSockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
'encrypted' => true,
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'ably' => [
'driver' => 'ably',
'key' => env('ABLY_KEY'),
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

277
config/horizon.php Normal file
View File

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

View File

@@ -3,11 +3,29 @@
return [ return [
'realtime' => (bool) env('MESSAGING_REALTIME', false), 'realtime' => (bool) env('MESSAGING_REALTIME', false),
'broadcast' => [
'queue' => env('MESSAGING_BROADCAST_QUEUE', 'broadcasts'),
],
'typing' => [ 'typing' => [
'ttl_seconds' => (int) env('MESSAGING_TYPING_TTL', 8), 'ttl_seconds' => (int) env('MESSAGING_TYPING_TTL', 8),
'cache_store' => env('MESSAGING_TYPING_CACHE_STORE', 'redis'), 'cache_store' => env('MESSAGING_TYPING_CACHE_STORE', 'redis'),
], ],
'presence' => [
'ttl_seconds' => (int) env('MESSAGING_PRESENCE_TTL', 90),
'conversation_ttl_seconds' => (int) env('MESSAGING_CONVERSATION_PRESENCE_TTL', 45),
'cache_store' => env('MESSAGING_PRESENCE_CACHE_STORE', env('MESSAGING_TYPING_CACHE_STORE', 'redis')),
],
'recovery' => [
'max_messages' => (int) env('MESSAGING_RECOVERY_MAX_MESSAGES', 100),
],
'notifications' => [
'offline_fallback_only' => (bool) env('MESSAGING_OFFLINE_FALLBACK_ONLY', true),
],
'search' => [ 'search' => [
'index' => env('MESSAGING_MEILI_INDEX', 'messages'), 'index' => env('MESSAGING_MEILI_INDEX', 'messages'),
'page_size' => (int) env('MESSAGING_SEARCH_PAGE_SIZE', 20), 'page_size' => (int) env('MESSAGING_SEARCH_PAGE_SIZE', 20),

96
config/reverb.php Normal file
View File

@@ -0,0 +1,96 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
|
| This option controls the default server used by Reverb to handle
| incoming messages as well as broadcasting message to all your
| connected clients. At this time only "reverb" is supported.
|
*/
'default' => env('REVERB_SERVER', 'reverb'),
/*
|--------------------------------------------------------------------------
| Reverb Servers
|--------------------------------------------------------------------------
|
| Here you may define details for each of the supported Reverb servers.
| Each server has its own configuration options that are defined in
| the array below. You should ensure all the options are present.
|
*/
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => env('REVERB_SERVER_PORT', 8080),
'path' => env('REVERB_SERVER_PATH', ''),
'hostname' => env('REVERB_HOST'),
'options' => [
'tls' => [],
],
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
'server' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', '6379'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'database' => env('REDIS_DB', '0'),
'timeout' => env('REDIS_TIMEOUT', 60),
],
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
/*
|--------------------------------------------------------------------------
| Reverb Applications
|--------------------------------------------------------------------------
|
| Here you may define how Reverb applications are managed. If you choose
| to use the "config" provider, you may define an array of apps which
| your server will support, including their connection credentials.
|
*/
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => ['*'],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
],
],
],
];

View File

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

View File

@@ -0,0 +1,192 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
public function up(): void
{
Schema::table('conversations', function (Blueprint $table): void {
if (! Schema::hasColumn('conversations', 'uuid')) {
$table->uuid('uuid')->nullable()->after('id')->unique();
}
if (! Schema::hasColumn('conversations', 'last_message_id')) {
$table->unsignedBigInteger('last_message_id')->nullable()->after('created_by')->index();
}
if (! Schema::hasColumn('conversations', 'is_active')) {
$table->boolean('is_active')->default(true)->after('last_message_at')->index();
}
});
Schema::table('conversation_participants', function (Blueprint $table): void {
if (! Schema::hasColumn('conversation_participants', 'last_read_message_id')) {
$table->unsignedBigInteger('last_read_message_id')->nullable()->after('last_read_at')->index();
}
if (! Schema::hasColumn('conversation_participants', 'is_hidden')) {
$table->boolean('is_hidden')->default(false)->after('is_archived');
}
$table->index(['user_id', 'last_read_at'], 'conversation_participants_user_last_read_idx');
});
Schema::table('messages', function (Blueprint $table): void {
if (! Schema::hasColumn('messages', 'uuid')) {
$table->uuid('uuid')->nullable()->after('id')->unique();
}
if (! Schema::hasColumn('messages', 'client_temp_id')) {
$table->string('client_temp_id', 120)->nullable()->after('uuid');
$table->index(['conversation_id', 'client_temp_id'], 'messages_conversation_client_temp_idx');
}
if (! Schema::hasColumn('messages', 'message_type')) {
$table->string('message_type', 32)->default('text')->after('sender_id');
}
if (! Schema::hasColumn('messages', 'meta_json')) {
$table->json('meta_json')->nullable()->after('body');
}
if (! Schema::hasColumn('messages', 'reply_to_message_id')) {
$table->unsignedBigInteger('reply_to_message_id')->nullable()->after('meta_json')->index();
}
});
Schema::table('message_attachments', function (Blueprint $table): void {
if (! Schema::hasColumn('message_attachments', 'disk')) {
$table->string('disk', 64)->default('local')->after('message_id');
}
});
if (! Schema::hasTable('message_reads')) {
Schema::create('message_reads', function (Blueprint $table): void {
$table->id();
$table->foreignId('message_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamp('read_at');
$table->unique(['message_id', 'user_id']);
$table->index('user_id');
});
}
DB::table('conversations')
->select('id')
->whereNull('uuid')
->orderBy('id')
->chunkById(200, function ($rows): void {
foreach ($rows as $row) {
DB::table('conversations')
->where('id', $row->id)
->update(['uuid' => (string) Str::uuid()]);
}
});
DB::table('messages')
->select('id')
->whereNull('uuid')
->orderBy('id')
->chunkById(200, function ($rows): void {
foreach ($rows as $row) {
DB::table('messages')
->where('id', $row->id)
->update(['uuid' => (string) Str::uuid()]);
}
});
DB::table('conversations')
->select('id')
->orderBy('id')
->chunkById(200, function ($rows): void {
foreach ($rows as $row) {
$lastMessageId = DB::table('messages')
->where('conversation_id', $row->id)
->whereNull('deleted_at')
->orderByDesc('created_at')
->orderByDesc('id')
->value('id');
DB::table('conversations')
->where('id', $row->id)
->update([
'last_message_id' => $lastMessageId,
'is_active' => true,
]);
}
});
}
public function down(): void
{
if (Schema::hasTable('message_reads')) {
Schema::drop('message_reads');
}
Schema::table('message_attachments', function (Blueprint $table): void {
if (Schema::hasColumn('message_attachments', 'disk')) {
$table->dropColumn('disk');
}
});
Schema::table('messages', function (Blueprint $table): void {
if (Schema::hasColumn('messages', 'reply_to_message_id')) {
$table->dropIndex('messages_reply_to_message_id_index');
$table->dropColumn('reply_to_message_id');
}
if (Schema::hasColumn('messages', 'meta_json')) {
$table->dropColumn('meta_json');
}
if (Schema::hasColumn('messages', 'message_type')) {
$table->dropColumn('message_type');
}
if (Schema::hasColumn('messages', 'client_temp_id')) {
$table->dropIndex('messages_conversation_client_temp_idx');
$table->dropColumn('client_temp_id');
}
if (Schema::hasColumn('messages', 'uuid')) {
$table->dropUnique(['uuid']);
$table->dropColumn('uuid');
}
});
Schema::table('conversation_participants', function (Blueprint $table): void {
if (Schema::hasColumn('conversation_participants', 'is_hidden')) {
$table->dropColumn('is_hidden');
}
if (Schema::hasColumn('conversation_participants', 'last_read_message_id')) {
$table->dropIndex('conversation_participants_last_read_message_id_index');
$table->dropColumn('last_read_message_id');
}
$table->dropIndex('conversation_participants_user_last_read_idx');
});
Schema::table('conversations', function (Blueprint $table): void {
if (Schema::hasColumn('conversations', 'is_active')) {
$table->dropColumn('is_active');
}
if (Schema::hasColumn('conversations', 'last_message_id')) {
$table->dropIndex('conversations_last_message_id_index');
$table->dropColumn('last_message_id');
}
if (Schema::hasColumn('conversations', 'uuid')) {
$table->dropUnique(['uuid']);
$table->dropColumn('uuid');
}
});
}
};

View File

@@ -0,0 +1,58 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('forum_spam_keywords')) {
Schema::create('forum_spam_keywords', function (Blueprint $table): void {
$table->id();
$table->string('keyword', 120)->unique();
$table->timestamp('created_at')->useCurrent();
});
}
if (!Schema::hasTable('forum_spam_domains')) {
Schema::create('forum_spam_domains', function (Blueprint $table): void {
$table->id();
$table->string('domain', 191)->unique();
$table->timestamp('created_at')->useCurrent();
});
}
foreach ((array) config('forum.moderation.defaults.keywords', []) as $keyword) {
$keyword = trim((string) $keyword);
if ($keyword === '') {
continue;
}
DB::table('forum_spam_keywords')->updateOrInsert(
['keyword' => $keyword],
['created_at' => now()]
);
}
foreach ((array) config('forum.moderation.defaults.domains', []) as $domain) {
$domain = strtolower(trim((string) $domain));
if ($domain === '') {
continue;
}
DB::table('forum_spam_domains')->updateOrInsert(
['domain' => $domain],
['created_at' => now()]
);
}
}
public function down(): void
{
Schema::dropIfExists('forum_spam_domains');
Schema::dropIfExists('forum_spam_keywords');
}
};

View File

@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('forum_spam_learning')) {
Schema::create('forum_spam_learning', function (Blueprint $table): void {
$table->id();
$table->string('content_hash', 64)->index();
$table->string('decision', 32)->index();
$table->string('pattern_signature', 191)->nullable()->index();
$table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete();
$table->json('metadata')->nullable();
$table->timestamp('created_at')->useCurrent();
});
}
if (!Schema::hasTable('forum_ai_logs')) {
Schema::create('forum_ai_logs', function (Blueprint $table): void {
$table->id();
$table->foreignId('post_id')->constrained('forum_posts')->cascadeOnDelete();
$table->unsignedSmallInteger('ai_score')->default(0);
$table->unsignedSmallInteger('behavior_score')->default(0);
$table->unsignedSmallInteger('link_score')->default(0);
$table->integer('learning_score')->default(0);
$table->unsignedSmallInteger('firewall_score')->default(0);
$table->unsignedSmallInteger('bot_risk_score')->default(0);
$table->unsignedSmallInteger('risk_score')->default(0)->index();
$table->string('decision', 32)->default('allow')->index();
$table->string('provider', 64)->nullable()->index();
$table->string('source_ip_hash', 64)->nullable()->index();
$table->json('metadata')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index(['post_id', 'created_at']);
});
}
}
public function down(): void
{
Schema::dropIfExists('forum_ai_logs');
Schema::dropIfExists('forum_spam_learning');
}
};

View File

@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('forum_spam_learning')) {
Schema::create('forum_spam_learning', function (Blueprint $table): void {
$table->id();
$table->string('content_hash', 64)->index();
$table->string('decision', 32)->index();
$table->string('pattern_signature', 191)->nullable()->index();
$table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete();
$table->json('metadata')->nullable();
$table->timestamp('created_at')->useCurrent();
});
}
if (!Schema::hasTable('forum_ai_logs')) {
Schema::create('forum_ai_logs', function (Blueprint $table): void {
$table->id();
$table->foreignId('post_id')->constrained('forum_posts')->cascadeOnDelete();
$table->unsignedSmallInteger('ai_score')->default(0);
$table->unsignedSmallInteger('behavior_score')->default(0);
$table->unsignedSmallInteger('link_score')->default(0);
$table->integer('learning_score')->default(0);
$table->unsignedSmallInteger('firewall_score')->default(0);
$table->unsignedSmallInteger('bot_risk_score')->default(0);
$table->unsignedSmallInteger('risk_score')->default(0)->index();
$table->string('decision', 32)->default('allow')->index();
$table->string('provider', 64)->nullable()->index();
$table->string('source_ip_hash', 64)->nullable()->index();
$table->json('metadata')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index(['post_id', 'created_at']);
});
}
}
public function down(): void
{
Schema::dropIfExists('forum_ai_logs');
Schema::dropIfExists('forum_spam_learning');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('forum_tags')) {
Schema::create('forum_tags', function (Blueprint $table): void {
$table->id();
$table->string('name', 80);
$table->string('slug', 80)->unique();
$table->timestamps();
});
}
if (!Schema::hasTable('forum_topic_tags')) {
Schema::create('forum_topic_tags', function (Blueprint $table): void {
$table->id();
$table->foreignId('topic_id')->constrained('forum_topics')->cascadeOnDelete();
$table->foreignId('tag_id')->constrained('forum_tags')->cascadeOnDelete();
$table->timestamps();
$table->unique(['topic_id', 'tag_id']);
});
}
}
public function down(): void
{
Schema::dropIfExists('forum_topic_tags');
Schema::dropIfExists('forum_tags');
}
};

View File

@@ -0,0 +1,111 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('uploads')) {
$missingUploadColumns = [
'tags' => ! Schema::hasColumn('uploads', 'tags'),
'license' => ! Schema::hasColumn('uploads', 'license'),
'nsfw' => ! Schema::hasColumn('uploads', 'nsfw'),
'is_scanned' => ! Schema::hasColumn('uploads', 'is_scanned'),
'has_tags' => ! Schema::hasColumn('uploads', 'has_tags'),
'published_at' => ! Schema::hasColumn('uploads', 'published_at'),
'final_path' => ! Schema::hasColumn('uploads', 'final_path'),
];
if (in_array(true, $missingUploadColumns, true)) {
Schema::table('uploads', function (Blueprint $table) use ($missingUploadColumns): void {
if ($missingUploadColumns['tags']) {
$table->json('tags')->nullable();
}
if ($missingUploadColumns['license']) {
$table->string('license', 64)->nullable();
}
if ($missingUploadColumns['nsfw']) {
$table->boolean('nsfw')->default(false);
}
if ($missingUploadColumns['is_scanned']) {
$table->boolean('is_scanned')->default(false)->index();
}
if ($missingUploadColumns['has_tags']) {
$table->boolean('has_tags')->default(false)->index();
}
if ($missingUploadColumns['published_at']) {
$table->timestamp('published_at')->nullable()->index();
}
if ($missingUploadColumns['final_path']) {
$table->string('final_path')->nullable();
}
});
}
}
if (Schema::hasTable('forum_ai_logs')) {
$missingForumAiColumns = [
'firewall_score' => ! Schema::hasColumn('forum_ai_logs', 'firewall_score'),
'bot_risk_score' => ! Schema::hasColumn('forum_ai_logs', 'bot_risk_score'),
];
if (in_array(true, $missingForumAiColumns, true)) {
Schema::table('forum_ai_logs', function (Blueprint $table) use ($missingForumAiColumns): void {
if ($missingForumAiColumns['firewall_score']) {
$table->unsignedSmallInteger('firewall_score')->default(0);
}
if ($missingForumAiColumns['bot_risk_score']) {
$table->unsignedSmallInteger('bot_risk_score')->default(0);
}
});
}
}
}
public function down(): void
{
if (Schema::hasTable('forum_ai_logs')) {
Schema::table('forum_ai_logs', function (Blueprint $table): void {
$dropColumns = [];
if (Schema::hasColumn('forum_ai_logs', 'firewall_score')) {
$dropColumns[] = 'firewall_score';
}
if (Schema::hasColumn('forum_ai_logs', 'bot_risk_score')) {
$dropColumns[] = 'bot_risk_score';
}
if ($dropColumns !== []) {
$table->dropColumn($dropColumns);
}
});
}
if (Schema::hasTable('uploads')) {
Schema::table('uploads', function (Blueprint $table): void {
$dropColumns = [];
foreach (['tags', 'license', 'nsfw', 'is_scanned', 'has_tags', 'published_at', 'final_path'] as $column) {
if (Schema::hasColumn('uploads', $column)) {
$dropColumns[] = $column;
}
}
if ($dropColumns !== []) {
$table->dropColumn($dropColumns);
}
});
}
}
};

View File

@@ -0,0 +1,44 @@
# Realtime Messaging
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, 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,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 `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, 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.

30
package-lock.json generated
View File

@@ -21,7 +21,9 @@
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"framer-motion": "^12.34.0", "framer-motion": "^12.34.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"laravel-echo": "^2.3.1",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"pusher-js": "^8.4.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -4125,6 +4127,19 @@
} }
} }
}, },
"node_modules/laravel-echo": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.3.1.tgz",
"integrity": "sha512-o6oD1oR+XklU9TO7OPGeLh/G9SjcZm+YrpSdGkOaAJf5HpXwZKt+wGgnULvKl5I8xUuUY/AAvqR24+y8suwZTA==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"pusher-js": "*",
"socket.io-client": "*"
}
},
"node_modules/laravel-vite-plugin": { "node_modules/laravel-vite-plugin": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.1.0.tgz", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.1.0.tgz",
@@ -5912,6 +5927,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/pusher-js": {
"version": "8.4.3",
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.3.tgz",
"integrity": "sha512-MYnVYhKxq2Oeg3HmTQxnKDj1oAZjqJCkEcYj8hYbH1Rw5pT0g8KtgOYVUKDRnyrPtwRvA9QR4wunwJW5xIbq0Q==",
"license": "MIT",
"dependencies": {
"tweetnacl": "^1.0.3"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.1", "version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
@@ -6807,6 +6831,12 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"license": "Unlicense"
},
"node_modules/uc.micro": { "node_modules/uc.micro": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",

View File

@@ -48,7 +48,9 @@
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"framer-motion": "^12.34.0", "framer-motion": "^12.34.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"laravel-echo": "^2.3.1",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"pusher-js": "^8.4.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",

File diff suppressed because one or more lines are too long

View File

@@ -85,6 +85,92 @@
.nova-scrollbar::-webkit-scrollbar-corner { .nova-scrollbar::-webkit-scrollbar-corner {
background: transparent; background: transparent;
} }
.nova-scrollbar-message {
scrollbar-width: thin;
scrollbar-color: rgba(56, 189, 248, 0.55) rgba(255,255,255,0.03);
}
.nova-scrollbar-message::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.nova-scrollbar-message::-webkit-scrollbar-track {
border-radius: 999px;
background:
linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)),
rgba(7, 11, 18, 0.72);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03);
}
.nova-scrollbar-message::-webkit-scrollbar-thumb {
border: 2px solid rgba(7, 11, 18, 0.72);
border-radius: 999px;
background:
linear-gradient(180deg, rgba(125, 211, 252, 0.9), rgba(14, 165, 233, 0.78) 55%, rgba(217, 70, 239, 0.68));
box-shadow:
0 0 0 1px rgba(125, 211, 252, 0.18),
0 6px 18px rgba(14, 165, 233, 0.22);
}
.nova-scrollbar-message::-webkit-scrollbar-thumb:hover {
background:
linear-gradient(180deg, rgba(186, 230, 253, 0.98), rgba(56, 189, 248, 0.9) 50%, rgba(232, 121, 249, 0.78));
box-shadow:
0 0 0 1px rgba(186, 230, 253, 0.24),
0 10px 24px rgba(56, 189, 248, 0.28);
}
.nova-scrollbar-message::-webkit-scrollbar-corner {
background: transparent;
}
.messages-page,
.messages-page * {
scrollbar-width: thin;
scrollbar-color: rgba(56, 189, 248, 0.55) rgba(255,255,255,0.03);
}
.messages-page::-webkit-scrollbar,
.messages-page *::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.messages-page::-webkit-scrollbar-track,
.messages-page *::-webkit-scrollbar-track {
border-radius: 999px;
background:
linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)),
rgba(7, 11, 18, 0.72);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03);
}
.messages-page::-webkit-scrollbar-thumb,
.messages-page *::-webkit-scrollbar-thumb {
border: 2px solid rgba(7, 11, 18, 0.72);
border-radius: 999px;
background:
linear-gradient(180deg, rgba(125, 211, 252, 0.9), rgba(14, 165, 233, 0.78) 55%, rgba(217, 70, 239, 0.68));
box-shadow:
0 0 0 1px rgba(125, 211, 252, 0.18),
0 6px 18px rgba(14, 165, 233, 0.22);
}
.messages-page::-webkit-scrollbar-thumb:hover,
.messages-page *::-webkit-scrollbar-thumb:hover {
background:
linear-gradient(180deg, rgba(186, 230, 253, 0.98), rgba(56, 189, 248, 0.9) 50%, rgba(232, 121, 249, 0.78));
box-shadow:
0 0 0 1px rgba(186, 230, 253, 0.24),
0 10px 24px rgba(56, 189, 248, 0.28);
}
.messages-page::-webkit-scrollbar-corner,
.messages-page *::-webkit-scrollbar-corner {
background: transparent;
}
} }
/* ─── TipTap rich text editor ─── */ /* ─── TipTap rich text editor ─── */

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from 'react' import React, { useState, useEffect, useCallback } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { getEcho } from '../../bootstrap'
import ConversationList from '../../components/messaging/ConversationList' import ConversationList from '../../components/messaging/ConversationList'
import ConversationThread from '../../components/messaging/ConversationThread' import ConversationThread from '../../components/messaging/ConversationThread'
import NewConversationModal from '../../components/messaging/NewConversationModal' import NewConversationModal from '../../components/messaging/NewConversationModal'
@@ -10,12 +11,17 @@ function getCsrf() {
async function apiFetch(url, options = {}) { async function apiFetch(url, options = {}) {
const isFormData = options.body instanceof FormData const isFormData = options.body instanceof FormData
const socketId = getEcho()?.socketId?.()
const headers = { const headers = {
'X-CSRF-TOKEN': getCsrf(), 'X-CSRF-TOKEN': getCsrf(),
Accept: 'application/json', Accept: 'application/json',
...options.headers, ...options.headers,
} }
if (socketId) {
headers['X-Socket-ID'] = socketId
}
if (!isFormData) { if (!isFormData) {
headers['Content-Type'] = 'application/json' headers['Content-Type'] = 'application/json'
} }
@@ -55,19 +61,23 @@ function buildSearchPreview(item) {
function MessagesPage({ userId, username, activeConversationId: initialId }) { function MessagesPage({ userId, username, activeConversationId: initialId }) {
const [conversations, setConversations] = useState([]) const [conversations, setConversations] = useState([])
const [unreadTotal, setUnreadTotal] = useState(null)
const [loadingConvs, setLoadingConvs] = useState(true) const [loadingConvs, setLoadingConvs] = useState(true)
const [activeId, setActiveId] = useState(initialId ?? null) const [activeId, setActiveId] = useState(initialId ?? null)
const [realtimeEnabled, setRealtimeEnabled] = useState(false) const [realtimeEnabled, setRealtimeEnabled] = useState(false)
const [realtimeStatus, setRealtimeStatus] = useState('offline')
const [onlineUserIds, setOnlineUserIds] = useState([])
const [typingByConversation, setTypingByConversation] = useState({})
const [showNewModal, setShowNewModal] = useState(false) const [showNewModal, setShowNewModal] = useState(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState([]) const [searchResults, setSearchResults] = useState([])
const [searching, setSearching] = useState(false) const [searching, setSearching] = useState(false)
const pollRef = useRef(null)
const loadConversations = useCallback(async () => { const loadConversations = useCallback(async () => {
try { try {
const data = await apiFetch('/api/messages/conversations') const data = await apiFetch('/api/messages/conversations')
setConversations(data.data ?? []) setConversations(data.data ?? [])
setUnreadTotal(Number.isFinite(Number(data?.summary?.unread_total)) ? Number(data.summary.unread_total) : null)
} catch (e) { } catch (e) {
console.error('Failed to load conversations', e) console.error('Failed to load conversations', e)
} finally { } finally {
@@ -81,28 +91,293 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
apiFetch('/api/messages/settings') apiFetch('/api/messages/settings')
.then((data) => setRealtimeEnabled(!!data?.realtime_enabled)) .then((data) => setRealtimeEnabled(!!data?.realtime_enabled))
.catch(() => setRealtimeEnabled(false)) .catch(() => setRealtimeEnabled(false))
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [loadConversations]) }, [loadConversations])
useEffect(() => { useEffect(() => {
if (pollRef.current) { const handlePopState = () => {
clearInterval(pollRef.current) const match = window.location.pathname.match(/^\/messages\/(\d+)$/)
pollRef.current = null setActiveId(match ? Number(match[1]) : null)
} }
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [])
useEffect(() => {
if (realtimeEnabled) { if (realtimeEnabled) {
return undefined return undefined
} }
pollRef.current = setInterval(loadConversations, 15000) const poll = window.setInterval(loadConversations, 15000)
return () => window.clearInterval(poll)
}, [loadConversations, realtimeEnabled])
useEffect(() => {
if (!realtimeEnabled || !userId) {
setRealtimeStatus('offline')
return undefined
}
const echo = getEcho()
if (!echo) {
setRealtimeStatus('offline')
return undefined
}
const connection = echo.connector?.pusher?.connection
let heartbeatId = null
const mapConnectionState = (state) => {
if (state === 'connected') {
return 'connected'
}
if (state === 'connecting' || state === 'initialized' || state === 'connecting_in') {
return 'connecting'
}
return 'offline'
}
const syncConnectionState = (payload = null) => {
const nextState = typeof payload?.current === 'string'
? payload.current
: connection?.state
if (echo.socketId?.()) {
setRealtimeStatus('connected')
return
}
setRealtimeStatus(mapConnectionState(nextState))
}
const handleVisibilitySync = () => {
if (document.visibilityState === 'visible') {
syncConnectionState()
}
}
syncConnectionState()
connection?.bind?.('state_change', syncConnectionState)
connection?.bind?.('connected', syncConnectionState)
connection?.bind?.('unavailable', syncConnectionState)
connection?.bind?.('disconnected', syncConnectionState)
heartbeatId = window.setInterval(syncConnectionState, 1000)
window.addEventListener('focus', syncConnectionState)
document.addEventListener('visibilitychange', handleVisibilitySync)
const channel = echo.private(`user.${userId}`)
const handleConversationUpdated = (payload) => {
const nextConversation = payload?.conversation
if (!nextConversation?.id) {
return
}
setConversations((prev) => mergeConversationSummary(prev, nextConversation))
const nextUnreadTotal = Number(payload?.summary?.unread_total)
if (Number.isFinite(nextUnreadTotal)) {
setUnreadTotal(nextUnreadTotal)
}
}
channel.listen('.conversation.updated', handleConversationUpdated)
return () => { return () => {
if (pollRef.current) clearInterval(pollRef.current) connection?.unbind?.('state_change', syncConnectionState)
connection?.unbind?.('connected', syncConnectionState)
connection?.unbind?.('unavailable', syncConnectionState)
connection?.unbind?.('disconnected', syncConnectionState)
if (heartbeatId) {
window.clearInterval(heartbeatId)
} }
}, [loadConversations, realtimeEnabled]) window.removeEventListener('focus', syncConnectionState)
document.removeEventListener('visibilitychange', handleVisibilitySync)
channel.stopListening('.conversation.updated', handleConversationUpdated)
echo.leaveChannel(`private-user.${userId}`)
}
}, [realtimeEnabled, userId])
useEffect(() => {
if (!realtimeEnabled || !userId) {
setOnlineUserIds([])
return undefined
}
const echo = getEcho()
if (!echo) {
setOnlineUserIds([])
return undefined
}
const setMembers = (users) => {
const nextIds = (users ?? [])
.map((user) => Number(user?.id))
.filter((id) => Number.isFinite(id) && id !== Number(userId))
setOnlineUserIds(Array.from(new Set(nextIds)))
}
const channel = echo.join('messaging')
channel
.here(setMembers)
.joining((user) => setOnlineUserIds((prev) => (
prev.includes(Number(user?.id)) || Number(user?.id) === Number(userId)
? prev
: [...prev, Number(user.id)]
)))
.leaving((user) => setOnlineUserIds((prev) => prev.filter((id) => id !== Number(user?.id))))
return () => {
echo.leave('messaging')
}
}, [realtimeEnabled, userId])
useEffect(() => {
if (!userId) {
return undefined
}
let intervalId = null
const sendHeartbeat = () => {
if (document.visibilityState === 'hidden') {
return
}
apiFetch('/api/messages/presence/heartbeat', {
method: 'POST',
body: JSON.stringify(activeId ? { conversation_id: activeId } : {}),
}).catch(() => {})
}
const handleVisibilitySync = () => {
if (document.visibilityState === 'visible') {
sendHeartbeat()
}
}
sendHeartbeat()
intervalId = window.setInterval(sendHeartbeat, 25000)
window.addEventListener('focus', sendHeartbeat)
document.addEventListener('visibilitychange', handleVisibilitySync)
return () => {
if (intervalId) {
window.clearInterval(intervalId)
}
window.removeEventListener('focus', sendHeartbeat)
document.removeEventListener('visibilitychange', handleVisibilitySync)
}
}, [activeId, userId])
useEffect(() => {
if (!realtimeEnabled) {
setTypingByConversation({})
return undefined
}
const echo = getEcho()
if (!echo || conversations.length === 0) {
return undefined
}
const timers = new Map()
const joinedChannels = []
const removeTypingUser = (conversationId, userIdToRemove) => {
const timerKey = `${conversationId}:${userIdToRemove}`
const existingTimer = timers.get(timerKey)
if (existingTimer) {
window.clearTimeout(existingTimer)
timers.delete(timerKey)
}
setTypingByConversation((prev) => {
const current = prev[conversationId] ?? []
const nextUsers = current.filter((user) => String(user.user_id ?? user.id) !== String(userIdToRemove))
if (nextUsers.length === current.length) {
return prev
}
if (nextUsers.length === 0) {
const next = { ...prev }
delete next[conversationId]
return next
}
return {
...prev,
[conversationId]: nextUsers,
}
})
}
conversations.forEach((conversation) => {
if (!conversation?.id) {
return
}
const conversationId = conversation.id
const channel = echo.join(`conversation.${conversationId}`)
joinedChannels.push(conversationId)
channel
.listen('.typing.started', (payload) => {
const user = payload?.user
if (!user?.id || user.id === userId) {
return
}
setTypingByConversation((prev) => {
const current = prev[conversationId] ?? []
const index = current.findIndex((entry) => String(entry.user_id ?? entry.id) === String(user.id))
const nextUser = { user_id: user.id, username: user.username }
if (index === -1) {
return {
...prev,
[conversationId]: [...current, nextUser],
}
}
const nextUsers = [...current]
nextUsers[index] = { ...nextUsers[index], ...nextUser }
return {
...prev,
[conversationId]: nextUsers,
}
})
const timerKey = `${conversationId}:${user.id}`
const existingTimer = timers.get(timerKey)
if (existingTimer) {
window.clearTimeout(existingTimer)
}
const timeout = window.setTimeout(() => removeTypingUser(conversationId, user.id), Number(payload?.expires_in_ms ?? 3500))
timers.set(timerKey, timeout)
})
.listen('.typing.stopped', (payload) => {
const typingUserId = payload?.user?.id
if (!typingUserId) {
return
}
removeTypingUser(conversationId, typingUserId)
})
})
return () => {
timers.forEach((timer) => window.clearTimeout(timer))
joinedChannels.forEach((conversationId) => {
echo.leave(`conversation.${conversationId}`)
})
}
}, [conversations, realtimeEnabled, userId])
const handleSelectConversation = useCallback((id) => { const handleSelectConversation = useCallback((id) => {
setActiveId(id) setActiveId(id)
@@ -116,12 +391,24 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
history.replaceState(null, '', `/messages/${conv.id}`) history.replaceState(null, '', `/messages/${conv.id}`)
}, [loadConversations]) }, [loadConversations])
const handleMarkRead = useCallback((conversationId) => { const handleMarkRead = useCallback((conversationId, nextUnreadTotal = null) => {
setConversations((prev) => prev.map((conversation) => ( setConversations((prev) => prev.map((conversation) => (
conversation.id === conversationId conversation.id === conversationId
? { ...conversation, unread_count: 0 } ? { ...conversation, unread_count: 0 }
: conversation : conversation
))) )))
if (Number.isFinite(Number(nextUnreadTotal))) {
setUnreadTotal(Number(nextUnreadTotal))
}
}, [])
const handleConversationPatched = useCallback((patch) => {
if (!patch?.id) {
return
}
setConversations((prev) => mergeConversationSummary(prev, patch))
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -167,7 +454,9 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
}, []) }, [])
const activeConversation = conversations.find((conversation) => conversation.id === activeId) ?? null const activeConversation = conversations.find((conversation) => conversation.id === activeId) ?? null
const unreadCount = conversations.reduce((sum, conversation) => sum + Number(conversation.unread_count || 0), 0) const unreadCount = Number.isFinite(Number(unreadTotal))
? Number(unreadTotal)
: conversations.reduce((sum, conversation) => sum + Number(conversation.unread_count || 0), 0)
const pinnedCount = conversations.reduce((sum, conversation) => { const pinnedCount = conversations.reduce((sum, conversation) => {
const me = conversation.my_participant ?? conversation.all_participants?.find((participant) => participant.user_id === userId) const me = conversation.my_participant ?? conversation.all_participants?.find((participant) => participant.user_id === userId)
return sum + (me?.is_pinned ? 1 : 0) return sum + (me?.is_pinned ? 1 : 0)
@@ -182,7 +471,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|| 'Conversation' || 'Conversation'
return ( return (
<div className="px-4 pb-16 pt-4 md:px-6 lg:px-8 lg:pt-6"> <div className="messages-page px-4 pb-16 pt-4 md:px-6 lg:px-8 lg:pt-6">
<div className="grid gap-5 lg:items-start lg:grid-cols-[340px_minmax(0,1fr)] xl:grid-cols-[360px_minmax(0,1fr)] xl:gap-6"> <div className="grid gap-5 lg:items-start lg:grid-cols-[340px_minmax(0,1fr)] xl:grid-cols-[360px_minmax(0,1fr)] xl:gap-6">
<aside className={`flex min-h-[calc(100vh-18rem)] flex-col overflow-hidden rounded-[30px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.96),rgba(7,11,18,0.92))] shadow-[0_20px_60px_rgba(0,0,0,0.28)] lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] ${activeId ? 'hidden lg:flex' : 'flex'}`}> <aside className={`flex min-h-[calc(100vh-18rem)] flex-col overflow-hidden rounded-[30px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.96),rgba(7,11,18,0.92))] shadow-[0_20px_60px_rgba(0,0,0,0.28)] lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] ${activeId ? 'hidden lg:flex' : 'flex'}`}>
<div className="border-b border-white/[0.06] p-5"> <div className="border-b border-white/[0.06] p-5">
@@ -209,9 +498,9 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
</div> </div>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-white/45"> <div className="mt-4 flex flex-wrap gap-2 text-xs text-white/45">
<span className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 ${realtimeEnabled ? 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200' : 'border-white/[0.08] bg-white/[0.04] text-white/55'}`}> <span className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 ${connectionBadgeClass(realtimeEnabled, realtimeStatus)}`}>
<span className={`h-1.5 w-1.5 rounded-full ${realtimeEnabled ? 'bg-emerald-300' : 'bg-white/30'}`} /> <span className={`h-1.5 w-1.5 rounded-full ${connectionDotClass(realtimeEnabled, realtimeStatus)}`} />
{realtimeEnabled ? 'Realtime active' : 'Polling every 15s'} {connectionBadgeLabel(realtimeEnabled, realtimeStatus)}
</span> </span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-white/55"> <span className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-white/55">
<i className="fa-solid fa-comments text-[10px]" /> <i className="fa-solid fa-comments text-[10px]" />
@@ -273,6 +562,8 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
loading={loadingConvs} loading={loadingConvs}
activeId={activeId} activeId={activeId}
currentUserId={userId} currentUserId={userId}
onlineUserIds={onlineUserIds}
typingByConversation={typingByConversation}
onSelect={handleSelectConversation} onSelect={handleSelectConversation}
/> />
</aside> </aside>
@@ -284,15 +575,17 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
conversationId={activeId} conversationId={activeId}
conversation={activeConversation} conversation={activeConversation}
realtimeEnabled={realtimeEnabled} realtimeEnabled={realtimeEnabled}
realtimeStatus={realtimeStatus}
currentUserId={userId} currentUserId={userId}
currentUsername={username} currentUsername={username}
onlineUserIds={onlineUserIds}
apiFetch={apiFetch} apiFetch={apiFetch}
onBack={() => { onBack={() => {
setActiveId(null) setActiveId(null)
history.replaceState(null, '', '/messages') history.replaceState(null, '', '/messages')
}} }}
onMarkRead={handleMarkRead} onMarkRead={handleMarkRead}
onConversationUpdated={loadConversations} onConversationPatched={handleConversationPatched}
/> />
) : ( ) : (
<div className="flex flex-1 items-center justify-center p-8"> <div className="flex flex-1 items-center justify-center p-8">
@@ -346,6 +639,83 @@ function StatChip({ label, value, tone = 'sky' }) {
) )
} }
function mergeConversationSummary(existing, incoming) {
const next = [...existing]
const index = next.findIndex((conversation) => conversation.id === incoming.id)
if (index >= 0) {
next[index] = { ...next[index], ...incoming }
} else {
next.unshift(incoming)
}
return next.sort((left, right) => {
const leftPinned = left.my_participant?.is_pinned ? 1 : 0
const rightPinned = right.my_participant?.is_pinned ? 1 : 0
if (leftPinned !== rightPinned) {
return rightPinned - leftPinned
}
const leftPinnedAt = left.my_participant?.pinned_at ? new Date(left.my_participant.pinned_at).getTime() : 0
const rightPinnedAt = right.my_participant?.pinned_at ? new Date(right.my_participant.pinned_at).getTime() : 0
if (leftPinnedAt !== rightPinnedAt) {
return rightPinnedAt - leftPinnedAt
}
const leftTime = left.last_message_at ? new Date(left.last_message_at).getTime() : 0
const rightTime = right.last_message_at ? new Date(right.last_message_at).getTime() : 0
return rightTime - leftTime
})
}
function connectionBadgeClass(realtimeEnabled, realtimeStatus) {
if (!realtimeEnabled) {
return 'border-white/[0.08] bg-white/[0.04] text-white/55'
}
if (realtimeStatus === 'connected') {
return 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200'
}
if (realtimeStatus === 'connecting') {
return 'border-amber-400/20 bg-amber-500/10 text-amber-200'
}
return 'border-rose-400/18 bg-rose-500/10 text-rose-200'
}
function connectionDotClass(realtimeEnabled, realtimeStatus) {
if (!realtimeEnabled) {
return 'bg-white/30'
}
if (realtimeStatus === 'connected') {
return 'bg-emerald-300'
}
if (realtimeStatus === 'connecting') {
return 'bg-amber-300'
}
return 'bg-rose-300'
}
function connectionBadgeLabel(realtimeEnabled, realtimeStatus) {
if (!realtimeEnabled) {
return 'Polling every 15s'
}
if (realtimeStatus === 'connected') {
return 'Realtime connected'
}
if (realtimeStatus === 'connecting') {
return 'Realtime connecting'
}
return 'Realtime disconnected'
}
const el = document.getElementById('messages-root') const el = document.getElementById('messages-root')
if (el) { if (el) {

View File

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

View File

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

View File

@@ -606,8 +606,64 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
if (uploadsV2Enabled) { if (uploadsV2Enabled) {
return ( return (
<section className="px-4 py-1"> <section className="min-h-[calc(100vh-4rem)] bg-[#07111c] text-slate-100">
<div className="max-w-6xl mx-auto"> <div className="relative isolate overflow-hidden">
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[420px] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.22),_transparent_32%),radial-gradient(circle_at_top_right,_rgba(251,146,60,0.16),_transparent_30%),linear-gradient(180deg,_rgba(8,17,28,0.98),_rgba(7,17,28,1))]" />
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
<div className="overflow-hidden rounded-[32px] border border-white/10 bg-[#08111c]/92 shadow-[0_30px_120px_rgba(2,8,23,0.38)]">
<div className="grid gap-8 border-b border-white/8 px-5 py-6 sm:px-8 lg:grid-cols-[1.45fr_0.85fr] lg:items-start lg:gap-10 lg:py-8">
<div>
<p className="text-[11px] uppercase tracking-[0.28em] text-sky-200/80">Skinbase Upload Studio</p>
<h1 className="mt-3 max-w-3xl text-3xl font-semibold tracking-tight text-white sm:text-4xl">
Upload artwork with less friction and better control.
</h1>
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-300 sm:text-base">
The upload flow now stays focused on three steps: add the file, finish the metadata, then publish with confidence. The interface is simpler, but the secure processing pipeline stays intact.
</p>
<div className="mt-6 grid gap-3 sm:grid-cols-3">
{[
{
title: 'Fast onboarding',
description: 'Clearer file requirements and a friendlier first step.',
},
{
title: 'Safer publishing',
description: 'Processing state, rights, and readiness stay visible the whole time.',
},
{
title: 'Cleaner review',
description: 'Metadata and publish options are easier to scan before going live.',
},
].map((item) => (
<div key={item.title} className="rounded-2xl border border-white/8 bg-white/[0.04] p-4">
<p className="text-sm font-semibold text-white">{item.title}</p>
<p className="mt-2 text-sm leading-6 text-slate-300">{item.description}</p>
</div>
))}
</div>
</div>
<aside className="rounded-[28px] border border-white/8 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-5 shadow-[0_18px_70px_rgba(0,0,0,0.16)]">
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Before you start</p>
<div className="mt-4 space-y-4">
{[
'Choose the final file you actually want published. Replacing after upload requires a reset.',
'ZIP, RAR, and 7Z packs still need at least one screenshot for preview generation.',
'You will confirm rights and visibility before the final publish step.',
].map((item, index) => (
<div key={item} className="flex items-start gap-3">
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-sky-300/20 bg-sky-400/10 text-xs font-semibold text-sky-100">
{index + 1}
</span>
<p className="text-sm leading-6 text-slate-300">{item}</p>
</div>
))}
</div>
</aside>
</div>
<div className="px-4 py-5 sm:px-6 lg:px-8 lg:py-8">
<UploadWizard <UploadWizard
initialDraftId={draftId ?? null} initialDraftId={draftId ?? null}
chunkSize={chunkSize} chunkSize={chunkSize}
@@ -615,6 +671,9 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
suggestedTags={Array.isArray(props?.suggested_tags) ? props.suggested_tags : []} suggestedTags={Array.isArray(props?.suggested_tags) ? props.suggested_tags : []}
/> />
</div> </div>
</div>
</div>
</div>
</section> </section>
) )
} }

View File

@@ -1,9 +1,51 @@
import axios from 'axios'; import axios from 'axios'
window.axios = axios; import Echo from 'laravel-echo'
import Pusher from 'pusher-js'
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; window.axios = axios
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
if (csrfToken) { if (csrfToken) {
window.axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken; window.axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken
}
window.Pusher = Pusher
let echoInstance = null
export function getEcho() {
if (echoInstance !== null) {
return echoInstance || null
}
const key = import.meta.env.VITE_REVERB_APP_KEY
if (!key) {
echoInstance = false
return null
}
const scheme = import.meta.env.VITE_REVERB_SCHEME || window.location.protocol.replace(':', '') || 'https'
const forceTLS = scheme === 'https'
echoInstance = new Echo({
broadcaster: 'reverb',
key,
wsHost: import.meta.env.VITE_REVERB_HOST || window.location.hostname,
wsPort: Number(import.meta.env.VITE_REVERB_PORT || (forceTLS ? 443 : 80)),
wssPort: Number(import.meta.env.VITE_REVERB_PORT || 443),
forceTLS,
enabledTransports: ['ws', 'wss'],
authEndpoint: '/broadcasting/auth',
auth: {
headers: {
'X-CSRF-TOKEN': csrfToken || '',
Accept: 'application/json',
},
},
})
window.Echo = echoInstance
return echoInstance
} }

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
export default function ConversationList({ conversations, loading, activeId, currentUserId, onSelect }) { export default function ConversationList({ conversations, loading, activeId, currentUserId, onlineUserIds = [], typingByConversation = {}, onSelect }) {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3"> <div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3">
@@ -13,7 +13,7 @@ export default function ConversationList({ conversations, loading, activeId, cur
</span> </span>
</div> </div>
<ul className="flex-1 space-y-2 overflow-y-auto p-3"> <ul className="nova-scrollbar-message flex-1 space-y-2 overflow-y-auto p-3 pr-2">
{loading ? ( {loading ? (
<li className="rounded-2xl border border-white/[0.06] bg-white/[0.025] px-4 py-8 text-center text-sm text-white/40">Loading conversations</li> <li className="rounded-2xl border border-white/[0.06] bg-white/[0.025] px-4 py-8 text-center text-sm text-white/40">Loading conversations</li>
) : null} ) : null}
@@ -28,6 +28,8 @@ export default function ConversationList({ conversations, loading, activeId, cur
conv={conversation} conv={conversation}
isActive={conversation.id === activeId} isActive={conversation.id === activeId}
currentUserId={currentUserId} currentUserId={currentUserId}
onlineUserIds={onlineUserIds}
typingUsers={typingByConversation[conversation.id] ?? []}
onClick={() => onSelect(conversation.id)} onClick={() => onSelect(conversation.id)}
/> />
))} ))}
@@ -36,16 +38,25 @@ export default function ConversationList({ conversations, loading, activeId, cur
) )
} }
function ConversationRow({ conv, isActive, currentUserId, onClick }) { function ConversationRow({ conv, isActive, currentUserId, onlineUserIds, typingUsers, onClick }) {
const label = convLabel(conv, currentUserId) const label = convLabel(conv, currentUserId)
const lastMsg = Array.isArray(conv.latest_message) ? conv.latest_message[0] : conv.latest_message const lastMsg = Array.isArray(conv.latest_message) ? conv.latest_message[0] : conv.latest_message
const preview = lastMsg?.body ? truncate(lastMsg.body, 88) : 'No messages yet' const preview = typingUsers.length > 0
? buildTypingPreview(typingUsers)
: lastMsg?.body ? truncate(lastMsg.body, 88) : 'No messages yet'
const unread = conv.unread_count ?? 0 const unread = conv.unread_count ?? 0
const myParticipant = conv.all_participants?.find((participant) => participant.user_id === currentUserId) const myParticipant = conv.all_participants?.find((participant) => participant.user_id === currentUserId)
const otherParticipant = conv.all_participants?.find((participant) => participant.user_id !== currentUserId)
const isArchived = myParticipant?.is_archived ?? false const isArchived = myParticipant?.is_archived ?? false
const isPinned = myParticipant?.is_pinned ?? false const isPinned = myParticipant?.is_pinned ?? false
const activeMembers = conv.all_participants?.filter((participant) => !participant.left_at).length ?? 0 const activeMembers = conv.all_participants?.filter((participant) => !participant.left_at).length ?? 0
const typeLabel = conv.type === 'group' ? `${activeMembers} members` : 'Direct message' const onlineMembers = conv.type === 'group'
? conv.all_participants?.filter((participant) => participant.user_id !== currentUserId && onlineUserIds.includes(Number(participant.user_id)) && !participant.left_at).length ?? 0
: 0
const isDirectOnline = conv.type === 'direct' && otherParticipant ? onlineUserIds.includes(Number(otherParticipant.user_id)) : false
const typeLabel = conv.type === 'group'
? (onlineMembers > 0 ? `${onlineMembers} online` : `${activeMembers} members`)
: (isDirectOnline ? 'Online now' : 'Direct message')
const senderLabel = lastMsg?.sender?.username ? `@${lastMsg.sender.username}` : null const senderLabel = lastMsg?.sender?.username ? `@${lastMsg.sender.username}` : null
const initials = label const initials = label
.split(/\s+/) .split(/\s+/)
@@ -61,9 +72,12 @@ function ConversationRow({ conv, isActive, currentUserId, onClick }) {
className={`w-full rounded-[24px] border px-4 py-4 text-left transition ${isActive ? 'border-sky-400/28 bg-sky-500/[0.12] shadow-[0_0_0_1px_rgba(56,189,248,0.08)]' : 'border-white/[0.06] bg-white/[0.03] hover:border-white/[0.1] hover:bg-white/[0.05]'} ${isArchived ? 'opacity-65' : ''}`} className={`w-full rounded-[24px] border px-4 py-4 text-left transition ${isActive ? 'border-sky-400/28 bg-sky-500/[0.12] shadow-[0_0_0_1px_rgba(56,189,248,0.08)]' : 'border-white/[0.06] bg-white/[0.03] hover:border-white/[0.1] hover:bg-white/[0.05]'} ${isArchived ? 'opacity-65' : ''}`}
> >
<div className="flex gap-3"> <div className="flex gap-3">
<div className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[0_10px_25px_rgba(0,0,0,0.18)] ${conv.type === 'group' ? 'bg-gradient-to-br from-fuchsia-500 to-violet-600' : 'bg-gradient-to-br from-sky-500 to-cyan-500'}`}> <div className="relative shrink-0">
<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} {initials}
</div> </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"> <div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -89,8 +103,11 @@ function ConversationRow({ conv, isActive, currentUserId, onClick }) {
</div> </div>
<div className="mt-3 rounded-2xl border border-white/[0.04] bg-black/10 px-3 py-2.5"> <div className="mt-3 rounded-2xl border border-white/[0.04] bg-black/10 px-3 py-2.5">
{senderLabel ? <p className="text-[11px] text-white/35">{senderLabel}</p> : null} {typingUsers.length === 0 && senderLabel ? <p className="text-[11px] text-white/35">{senderLabel}</p> : null}
<p className="mt-1 truncate text-sm text-white/62">{preview}</p> <p className={`mt-1 flex items-center gap-2 truncate text-sm ${typingUsers.length > 0 ? 'text-emerald-200' : 'text-white/62'}`}>
{typingUsers.length > 0 ? <SidebarTypingIcon /> : null}
<span className="truncate">{preview}</span>
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -110,6 +127,23 @@ function truncate(str, max) {
return str.length > max ? `${str.slice(0, max)}` : str return str.length > max ? `${str.slice(0, max)}` : str
} }
function buildTypingPreview(users) {
const names = users.map((user) => `@${user.username}`)
if (names.length === 1) return `${names[0]} is typing...`
if (names.length === 2) return `${names[0]} and ${names[1]} are typing...`
return `${names[0]}, ${names[1]} and ${names.length - 2} others are typing...`
}
function SidebarTypingIcon() {
return (
<span className="inline-flex shrink-0 items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300 animate-[pulse_1s_ease-in-out_infinite]" />
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300/80 animate-[pulse_1s_ease-in-out_150ms_infinite]" />
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300/60 animate-[pulse_1s_ease-in-out_300ms_infinite]" />
</span>
)
}
function relativeTime(iso) { function relativeTime(iso) {
if (!iso) return 'No activity' if (!iso) return 'No activity'
const diff = (Date.now() - new Date(iso).getTime()) / 1000 const diff = (Date.now() - new Date(iso).getTime()) / 1000

View File

@@ -1,16 +1,19 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { getEcho } from '../../bootstrap'
import MessageBubble from './MessageBubble' import MessageBubble from './MessageBubble'
export default function ConversationThread({ export default function ConversationThread({
conversationId, conversationId,
conversation, conversation,
realtimeEnabled, realtimeEnabled,
realtimeStatus,
currentUserId, currentUserId,
currentUsername, currentUsername,
onlineUserIds,
apiFetch, apiFetch,
onBack, onBack,
onMarkRead, onMarkRead,
onConversationUpdated, onConversationPatched,
}) { }) {
const [messages, setMessages] = useState([]) const [messages, setMessages] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -21,6 +24,8 @@ export default function ConversationThread({
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [typingUsers, setTypingUsers] = useState([]) const [typingUsers, setTypingUsers] = useState([])
const [participantState, setParticipantState] = useState(conversation?.all_participants ?? [])
const [presenceUsers, setPresenceUsers] = useState([])
const [threadSearch, setThreadSearch] = useState('') const [threadSearch, setThreadSearch] = useState('')
const [busyAction, setBusyAction] = useState(null) const [busyAction, setBusyAction] = useState(null)
const [lightbox, setLightbox] = useState(null) const [lightbox, setLightbox] = useState(null)
@@ -29,8 +34,15 @@ export default function ConversationThread({
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
const typingRef = useRef(null) const typingRef = useRef(null)
const stopTypingRef = useRef(null) const stopTypingRef = useRef(null)
const readReceiptRef = useRef(null)
const typingExpiryTimersRef = useRef(new Map())
const messagesRef = useRef([])
const lastStartRef = useRef(0) const lastStartRef = useRef(0)
const initialLoadRef = useRef(true) const initialLoadRef = useRef(true)
const shouldStickToBottomRef = useRef(true)
const previousScrollHeightRef = useRef(0)
const pendingPrependRef = useRef(false)
const pendingComposerScrollRef = useRef(false)
const knownMessageIdsRef = useRef(new Set()) const knownMessageIdsRef = useRef(new Set())
const animatedMessageIdsRef = useRef(new Set()) const animatedMessageIdsRef = useRef(new Set())
const [animatedMessageIds, setAnimatedMessageIds] = useState({}) const [animatedMessageIds, setAnimatedMessageIds] = useState({})
@@ -38,16 +50,29 @@ export default function ConversationThread({
const myParticipant = useMemo(() => ( const myParticipant = useMemo(() => (
conversation?.my_participant conversation?.my_participant
?? conversation?.all_participants?.find((participant) => participant.user_id === currentUserId) ?? participantState.find((participant) => participant.user_id === currentUserId)
?? null ?? null
), [conversation, currentUserId]) ), [conversation, currentUserId, participantState])
const participants = useMemo(() => conversation?.all_participants ?? [], [conversation]) const participants = useMemo(() => participantState, [participantState])
const participantNames = useMemo(() => ( const participantNames = useMemo(() => (
participants participants
.map((participant) => participant.user?.username) .map((participant) => participant.user?.username)
.filter(Boolean) .filter(Boolean)
), [participants]) ), [participants])
const remoteParticipantNames = useMemo(() => (
participants
.filter((participant) => participant.user_id !== currentUserId)
.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 filteredMessages = useMemo(() => {
const query = threadSearch.trim().toLowerCase() const query = threadSearch.trim().toLowerCase()
@@ -66,10 +91,66 @@ export default function ConversationThread({
return participants.find((participant) => participant.user_id !== currentUserId)?.user?.username ?? 'Direct message' return participants.find((participant) => participant.user_id !== currentUserId)?.user?.username ?? 'Direct message'
}, [conversation, currentUserId, participants]) }, [conversation, currentUserId, participants])
const patchConversation = useCallback((patch) => {
if (!patch) {
return
}
onConversationPatched?.({
id: conversationId,
...patch,
})
}, [conversationId, onConversationPatched])
const patchLastMessage = useCallback((message, extra = {}) => {
if (!message) {
return
}
patchConversation({
latest_message: message,
last_message_at: message.created_at ?? new Date().toISOString(),
...extra,
})
}, [patchConversation])
const patchMyParticipantState = useCallback((changes) => {
if (!changes) {
return
}
setParticipantState((prev) => prev.map((participant) => (
participant.user_id === currentUserId
? { ...participant, ...changes }
: participant
)))
patchConversation({
my_participant: {
...(myParticipant ?? {}),
...changes,
},
})
}, [currentUserId, myParticipant, patchConversation])
const scrollToBottom = useCallback(() => {
if (!listRef.current) {
return
}
listRef.current.scrollTop = listRef.current.scrollHeight
shouldStickToBottomRef.current = true
}, [])
const loadMessages = useCallback(async ({ cursor = null, append = false, silent = false } = {}) => { const loadMessages = useCallback(async ({ cursor = null, append = false, silent = false } = {}) => {
if (append) setLoadingMore(true) if (append) setLoadingMore(true)
else if (!silent) setLoading(true) else if (!silent) setLoading(true)
if (append && listRef.current) {
previousScrollHeightRef.current = listRef.current.scrollHeight
pendingPrependRef.current = true
}
try { try {
const url = cursor const url = cursor
? `/api/messages/${conversationId}?cursor=${encodeURIComponent(cursor)}` ? `/api/messages/${conversationId}?cursor=${encodeURIComponent(cursor)}`
@@ -96,30 +177,76 @@ export default function ConversationThread({
} }
}, [apiFetch, conversationId]) }, [apiFetch, conversationId])
const markConversationRead = useCallback(async (messageId = null) => {
try {
const response = await apiFetch(`/api/messages/${conversationId}/read`, {
method: 'POST',
body: JSON.stringify(messageId ? { message_id: messageId } : {}),
})
setParticipantState((prev) => prev.map((participant) => (
participant.user_id === currentUserId
? {
...participant,
last_read_at: response.last_read_at ?? new Date().toISOString(),
last_read_message_id: response.last_read_message_id ?? messageId ?? participant.last_read_message_id,
}
: participant
)))
onMarkRead?.(conversationId, response?.unread_total ?? null)
} catch {
// no-op
}
}, [apiFetch, conversationId, currentUserId, onMarkRead])
const queueReadReceipt = useCallback((messageId = null) => {
if (readReceiptRef.current) {
window.clearTimeout(readReceiptRef.current)
}
readReceiptRef.current = window.setTimeout(() => {
markConversationRead(messageId)
}, 220)
}, [markConversationRead])
useEffect(() => { useEffect(() => {
initialLoadRef.current = true initialLoadRef.current = true
shouldStickToBottomRef.current = true
previousScrollHeightRef.current = 0
pendingPrependRef.current = false
pendingComposerScrollRef.current = false
knownMessageIdsRef.current = new Set()
animatedMessageIdsRef.current = new Set()
setMessages([]) setMessages([])
setPresenceUsers([])
setTypingUsers([])
setNextCursor(null) setNextCursor(null)
setBody('') setBody('')
setFiles([]) setFiles([])
setDraftTitle(conversation?.title ?? '')
loadMessages() loadMessages()
if (!realtimeEnabled) {
loadTyping() loadTyping()
}, [conversation?.title, conversationId, loadMessages, loadTyping]) }
}, [conversationId, loadMessages, loadTyping, realtimeEnabled])
useEffect(() => { useEffect(() => {
apiFetch(`/api/messages/${conversationId}/read`, { method: 'POST' }) setParticipantState(conversation?.all_participants ?? [])
.then(() => onMarkRead?.(conversationId)) setDraftTitle(conversation?.title ?? '')
.catch(() => {}) }, [conversation?.all_participants, conversation?.title])
}, [apiFetch, conversationId, onMarkRead])
useEffect(() => { useEffect(() => {
markConversationRead()
}, [markConversationRead])
useEffect(() => {
if (realtimeEnabled) {
return undefined
}
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
loadTyping() loadTyping()
if (!realtimeEnabled) {
loadMessages({ silent: true }) loadMessages({ silent: true })
} }, 8000)
}, realtimeEnabled ? 5000 : 8000)
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [loadMessages, loadTyping, realtimeEnabled]) }, [loadMessages, loadTyping, realtimeEnabled])
@@ -127,21 +254,176 @@ export default function ConversationThread({
useEffect(() => () => { useEffect(() => () => {
if (typingRef.current) window.clearTimeout(typingRef.current) if (typingRef.current) window.clearTimeout(typingRef.current)
if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current) if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current)
if (readReceiptRef.current) window.clearTimeout(readReceiptRef.current)
typingExpiryTimersRef.current.forEach((timer) => window.clearTimeout(timer))
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!listRef.current) return messagesRef.current = messages
if (initialLoadRef.current) { }, [messages])
listRef.current.scrollTop = listRef.current.scrollHeight
initialLoadRef.current = false useLayoutEffect(() => {
const container = listRef.current
if (!container) {
return return
} }
const nearBottom = listRef.current.scrollHeight - listRef.current.scrollTop - listRef.current.clientHeight < 180 if (initialLoadRef.current) {
if (nearBottom) { scrollToBottom()
listRef.current.scrollTop = listRef.current.scrollHeight initialLoadRef.current = false
previousScrollHeightRef.current = container.scrollHeight
return
} }
}, [messages.length])
if (pendingPrependRef.current) {
const heightDelta = container.scrollHeight - previousScrollHeightRef.current
container.scrollTop += heightDelta
pendingPrependRef.current = false
previousScrollHeightRef.current = container.scrollHeight
return
}
const latestMessage = messages[messages.length - 1] ?? null
const shouldScroll = pendingComposerScrollRef.current
|| shouldStickToBottomRef.current
|| latestMessage?._optimistic
|| latestMessage?.sender_id === currentUserId
if (shouldScroll) {
scrollToBottom()
}
pendingComposerScrollRef.current = false
previousScrollHeightRef.current = container.scrollHeight
}, [currentUserId, messages, scrollToBottom])
useEffect(() => {
if (!realtimeEnabled) {
return undefined
}
const echo = getEcho()
if (!echo) {
return undefined
}
const syncMissedMessages = async () => {
const lastServerMessage = [...messagesRef.current]
.reverse()
.find((message) => Number.isFinite(Number(message.id)) && !message._optimistic)
if (!lastServerMessage?.id) {
return
}
try {
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))
}
} catch {
// no-op
}
}
const handleMessageCreated = (payload) => {
if (!payload?.message) return
const incoming = normalizeMessage(payload.message, currentUserId)
setMessages((prev) => mergeMessageLists(prev, [incoming]))
patchLastMessage(incoming, { unread_count: 0 })
if (incoming.sender_id !== currentUserId && document.visibilityState === 'visible') {
queueReadReceipt(incoming.id)
}
}
const handleMessageUpdated = (payload) => {
if (!payload?.message) return
const updated = normalizeMessage(payload.message, currentUserId)
setMessages((prev) => mergeMessageLists(prev, [updated]))
patchLastMessage(updated)
}
const handleMessageDeleted = (payload) => {
const deletedAt = payload?.deleted_at ?? new Date().toISOString()
setMessages((prev) => prev.map((message) => (
messagesMatch(message, payload)
? { ...message, body: '', deleted_at: deletedAt, attachments: [] }
: message
)))
patchConversation({ last_message_at: deletedAt })
}
const handleMessageRead = (payload) => {
if (!payload?.user?.id) return
setParticipantState((prev) => prev.map((participant) => (
participant.user_id === payload.user.id
? {
...participant,
last_read_at: payload.last_read_at ?? participant.last_read_at,
last_read_message_id: payload.last_read_message_id ?? participant.last_read_message_id,
}
: participant
)))
}
const removeTypingUser = (userId) => {
const existingTimer = typingExpiryTimersRef.current.get(userId)
if (existingTimer) {
window.clearTimeout(existingTimer)
typingExpiryTimersRef.current.delete(userId)
}
setTypingUsers((prev) => prev.filter((user) => user.user_id !== userId && user.id !== userId))
}
const handleTypingStarted = (payload) => {
const user = payload?.user
if (!user?.id || user.id === currentUserId) return
setTypingUsers((prev) => mergeTypingUsers(prev, {
user_id: user.id,
username: user.username,
}))
removeTypingUser(user.id)
const timeout = window.setTimeout(() => removeTypingUser(user.id), Number(payload?.expires_in_ms ?? 3500))
typingExpiryTimersRef.current.set(user.id, timeout)
}
const handleTypingStopped = (payload) => {
const userId = payload?.user?.id
if (!userId) return
removeTypingUser(userId)
}
const privateChannel = echo.private(`conversation.${conversationId}`)
privateChannel.listen('.message.created', handleMessageCreated)
privateChannel.listen('.message.updated', handleMessageUpdated)
privateChannel.listen('.message.deleted', handleMessageDeleted)
privateChannel.listen('.message.read', handleMessageRead)
const presenceChannel = echo.join(`conversation.${conversationId}`)
presenceChannel
.here((users) => setPresenceUsers(normalizePresenceUsers(users, currentUserId)))
.joining((user) => setPresenceUsers((prev) => mergePresenceUsers(prev, user, currentUserId)))
.leaving((user) => setPresenceUsers((prev) => prev.filter((member) => member.id !== user?.id)))
.listen('.typing.started', handleTypingStarted)
.listen('.typing.stopped', handleTypingStopped)
const connection = echo.connector?.pusher?.connection
connection?.bind?.('connected', syncMissedMessages)
syncMissedMessages()
return () => {
connection?.unbind?.('connected', syncMissedMessages)
typingExpiryTimersRef.current.forEach((timer) => window.clearTimeout(timer))
typingExpiryTimersRef.current.clear()
echo.leave(`conversation.${conversationId}`)
}
}, [apiFetch, conversationId, currentUserId, patchConversation, patchLastMessage, queueReadReceipt, realtimeEnabled])
useEffect(() => { useEffect(() => {
const known = knownMessageIdsRef.current const known = knownMessageIdsRef.current
@@ -183,6 +465,12 @@ export default function ConversationThread({
const handleBodyChange = useCallback((value) => { const handleBodyChange = useCallback((value) => {
setBody(value) setBody(value)
if (value.trim() === '') {
if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current)
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
return
}
const now = Date.now() const now = Date.now()
if (now - lastStartRef.current > 2500) { if (now - lastStartRef.current > 2500) {
lastStartRef.current = now lastStartRef.current = now
@@ -208,8 +496,10 @@ export default function ConversationThread({
if (!trimmed && files.length === 0) return if (!trimmed && files.length === 0) return
const optimisticId = `optimistic-${Date.now()}` const optimisticId = `optimistic-${Date.now()}`
const clientTempId = `tmp_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
const optimisticMessage = normalizeMessage({ const optimisticMessage = normalizeMessage({
id: optimisticId, id: optimisticId,
client_temp_id: clientTempId,
body: trimmed, body: trimmed,
sender: { id: currentUserId, username: currentUsername }, sender: { id: currentUserId, username: currentUsername },
sender_id: currentUserId, sender_id: currentUserId,
@@ -223,6 +513,8 @@ export default function ConversationThread({
_optimistic: true, _optimistic: true,
}, currentUserId) }, currentUserId)
pendingComposerScrollRef.current = true
shouldStickToBottomRef.current = true
setMessages((prev) => mergeMessageLists(prev, [optimisticMessage])) setMessages((prev) => mergeMessageLists(prev, [optimisticMessage]))
setBody('') setBody('')
setFiles([]) setFiles([])
@@ -232,6 +524,7 @@ export default function ConversationThread({
const formData = new FormData() const formData = new FormData()
if (trimmed) formData.append('body', trimmed) if (trimmed) formData.append('body', trimmed)
formData.append('client_temp_id', clientTempId)
files.forEach((file) => formData.append('attachments[]', file)) files.forEach((file) => formData.append('attachments[]', file))
try { try {
@@ -241,10 +534,10 @@ export default function ConversationThread({
}) })
const normalized = normalizeMessage(created, currentUserId) const normalized = normalizeMessage(created, currentUserId)
setMessages((prev) => prev.map((message) => message.id === optimisticId ? normalized : message)) setMessages((prev) => mergeMessageLists(prev, [normalized]))
onConversationUpdated?.() patchLastMessage(normalized, { unread_count: 0 })
} catch (err) { } catch (err) {
setMessages((prev) => prev.filter((message) => message.id !== optimisticId)) setMessages((prev) => prev.filter((message) => !messagesMatch(message, { id: optimisticId, client_temp_id: clientTempId })))
setBody(trimmed) setBody(trimmed)
setFiles(files) setFiles(files)
setError(err.message) setError(err.message)
@@ -252,7 +545,7 @@ export default function ConversationThread({
setSending(false) setSending(false)
apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {}) apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
} }
}, [apiFetch, body, conversationId, currentUserId, currentUsername, files, onConversationUpdated, sending]) }, [apiFetch, body, conversationId, currentUserId, currentUsername, files, patchLastMessage, sending])
const updateReactions = useCallback((messageId, summary) => { const updateReactions = useCallback((messageId, summary) => {
setMessages((prev) => prev.map((message) => { setMessages((prev) => prev.map((message) => {
@@ -289,8 +582,8 @@ export default function ConversationThread({
setMessages((prev) => prev.map((message) => ( setMessages((prev) => prev.map((message) => (
message.id === messageId ? normalizeMessage({ ...message, ...updated }, currentUserId) : message message.id === messageId ? normalizeMessage({ ...message, ...updated }, currentUserId) : message
))) )))
onConversationUpdated?.() patchLastMessage(normalizeMessage(updated, currentUserId))
}, [apiFetch, currentUserId, onConversationUpdated]) }, [apiFetch, currentUserId, patchLastMessage])
const handleDelete = useCallback(async (messageId) => { const handleDelete = useCallback(async (messageId) => {
await apiFetch(`/api/messages/message/${messageId}`, { method: 'DELETE' }) await apiFetch(`/api/messages/message/${messageId}`, { method: 'DELETE' })
@@ -299,8 +592,8 @@ export default function ConversationThread({
? { ...message, body: '', deleted_at: new Date().toISOString(), attachments: [] } ? { ...message, body: '', deleted_at: new Date().toISOString(), attachments: [] }
: message : message
))) )))
onConversationUpdated?.() patchConversation({ last_message_at: new Date().toISOString() })
}, [apiFetch, onConversationUpdated]) }, [apiFetch, patchConversation])
const runConversationAction = useCallback(async (action, url, apply) => { const runConversationAction = useCallback(async (action, url, apply) => {
setBusyAction(action) setBusyAction(action)
@@ -308,14 +601,13 @@ export default function ConversationThread({
try { try {
const response = await apiFetch(url, { method: action === 'leave' ? 'DELETE' : 'POST' }) const response = await apiFetch(url, { method: action === 'leave' ? 'DELETE' : 'POST' })
apply?.(response) apply?.(response)
onConversationUpdated?.()
if (action === 'leave') onBack?.() if (action === 'leave') onBack?.()
} catch (e) { } catch (e) {
setError(e.message) setError(e.message)
} finally { } finally {
setBusyAction(null) setBusyAction(null)
} }
}, [apiFetch, onBack, onConversationUpdated]) }, [apiFetch, onBack])
const handleRename = useCallback(async () => { const handleRename = useCallback(async () => {
const title = draftTitle.trim() const title = draftTitle.trim()
@@ -329,17 +621,23 @@ export default function ConversationThread({
method: 'POST', method: 'POST',
body: JSON.stringify({ title }), body: JSON.stringify({ title }),
}) })
onConversationUpdated?.() patchConversation({ title })
} catch (e) { } catch (e) {
setError(e.message) setError(e.message)
} finally { } finally {
setBusyAction(null) setBusyAction(null)
} }
}, [apiFetch, conversation?.title, conversationId, draftTitle, onConversationUpdated]) }, [apiFetch, conversation?.title, conversationId, draftTitle, patchConversation])
const visibleMessages = filteredMessages const visibleMessages = filteredMessages
const messagesWithDecorators = useMemo(() => decorateMessages(visibleMessages, currentUserId, myParticipant?.last_read_at ?? null), [visibleMessages, currentUserId, myParticipant?.last_read_at]) const messagesWithDecorators = useMemo(() => decorateMessages(visibleMessages, currentUserId, myParticipant), [visibleMessages, currentUserId, myParticipant])
const typingLabel = buildTypingLabel(typingUsers) const typingLabel = buildTypingLabel(typingUsers)
const presenceLabel = 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
return ( return (
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
@@ -356,6 +654,7 @@ export default function ConversationThread({
<div className="mt-1 flex flex-wrap items-center gap-2"> <div className="mt-1 flex flex-wrap items-center gap-2">
<h2 className="truncate text-2xl font-semibold text-white">{conversationLabel}</h2> <h2 className="truncate text-2xl font-semibold text-white">{conversationLabel}</h2>
{conversation?.type === 'group' ? <span className="rounded-full border border-fuchsia-400/20 bg-fuchsia-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-fuchsia-200">Group</span> : <span className="rounded-full border border-sky-400/20 bg-sky-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-200">Direct</span>} {conversation?.type === 'group' ? <span className="rounded-full border border-fuchsia-400/20 bg-fuchsia-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-fuchsia-200">Group</span> : <span className="rounded-full border border-sky-400/20 bg-sky-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-200">Direct</span>}
{realtimeEnabled ? <RealtimeStatusBadge status={realtimeStatus} /> : null}
{myParticipant?.is_pinned ? <span className="rounded-full border border-amber-400/20 bg-amber-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-200">Pinned</span> : null} {myParticipant?.is_pinned ? <span className="rounded-full border border-amber-400/20 bg-amber-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-200">Pinned</span> : null}
{myParticipant?.is_muted ? <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-white/45">Muted</span> : null} {myParticipant?.is_muted ? <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-white/45">Muted</span> : null}
{myParticipant?.is_archived ? <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-white/45">Archived</span> : null} {myParticipant?.is_archived ? <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-white/45">Archived</span> : null}
@@ -366,12 +665,18 @@ export default function ConversationThread({
? `Participants: ${participantNames.join(', ')}` ? `Participants: ${participantNames.join(', ')}`
: `Private thread with ${participantNames.find((name) => name !== currentUsername) ?? conversationLabel}.`} : `Private thread with ${participantNames.find((name) => name !== currentUsername) ?? conversationLabel}.`}
</p> </p>
{typingSummary ? (
<div className="mt-2 inline-flex items-center gap-2 rounded-full border border-emerald-400/18 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-100/90">
<TypingPulse />
<span>{typingSummary}</span>
</div>
) : presenceLabel ? <p className="mt-1 text-xs text-emerald-200/70">{presenceLabel}</p> : conversation?.type === 'direct' && remoteParticipantNames.length > 0 ? <p className="mt-1 text-xs text-white/38">Chatting with @{remoteParticipantNames[0]} in realtime.</p> : null}
</div> </div>
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1 lg:mx-0 lg:flex-wrap lg:overflow-visible lg:px-0 lg:pb-0"> <div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1 lg:mx-0 lg:flex-wrap lg:overflow-visible lg:px-0 lg:pb-0">
<button <button
onClick={() => runConversationAction('archive', `/api/messages/${conversationId}/archive`, (response) => { onClick={() => runConversationAction('archive', `/api/messages/${conversationId}/archive`, (response) => {
if (conversation?.my_participant) conversation.my_participant.is_archived = !!response.is_archived patchMyParticipantState({ is_archived: !!response.is_archived })
})} })}
className={actionButtonClass(busyAction === 'archive')} className={actionButtonClass(busyAction === 'archive')}
> >
@@ -380,7 +685,7 @@ export default function ConversationThread({
</button> </button>
<button <button
onClick={() => runConversationAction('mute', `/api/messages/${conversationId}/mute`, (response) => { onClick={() => runConversationAction('mute', `/api/messages/${conversationId}/mute`, (response) => {
if (conversation?.my_participant) conversation.my_participant.is_muted = !!response.is_muted patchMyParticipantState({ is_muted: !!response.is_muted })
})} })}
className={actionButtonClass(busyAction === 'mute')} className={actionButtonClass(busyAction === 'mute')}
> >
@@ -389,7 +694,10 @@ export default function ConversationThread({
</button> </button>
<button <button
onClick={() => runConversationAction(myParticipant?.is_pinned ? 'unpin' : 'pin', `/api/messages/${conversationId}/${myParticipant?.is_pinned ? 'unpin' : 'pin'}`, (response) => { onClick={() => runConversationAction(myParticipant?.is_pinned ? 'unpin' : 'pin', `/api/messages/${conversationId}/${myParticipant?.is_pinned ? 'unpin' : 'pin'}`, (response) => {
if (conversation?.my_participant) conversation.my_participant.is_pinned = !!response.is_pinned patchMyParticipantState({
is_pinned: !!response.is_pinned,
pinned_at: response.is_pinned ? new Date().toISOString() : null,
})
})} })}
className={actionButtonClass(busyAction === 'pin' || busyAction === 'unpin')} className={actionButtonClass(busyAction === 'pin' || busyAction === 'unpin')}
> >
@@ -449,7 +757,17 @@ export default function ConversationThread({
</div> </div>
) : null} ) : null}
<div ref={listRef} className="flex-1 overflow-y-auto px-3 py-4 sm:px-6 sm:py-5"> <div
ref={listRef}
onScroll={() => {
if (!listRef.current) {
return
}
shouldStickToBottomRef.current = isNearBottom(listRef.current)
}}
className="nova-scrollbar-message flex-1 overflow-y-auto px-3 py-4 pr-2 sm:px-6 sm:py-5"
>
{nextCursor ? ( {nextCursor ? (
<div className="mb-4 flex justify-center"> <div className="mb-4 flex justify-center">
<button <button
@@ -488,7 +806,7 @@ export default function ConversationThread({
const showAvatar = !previous || previous.sender_id !== message.sender_id const showAvatar = !previous || previous.sender_id !== message.sender_id
const endsSequence = !next || next.sender_id !== message.sender_id const endsSequence = !next || next.sender_id !== message.sender_id
const seenText = isLastMineMessage(visibleMessages, index, currentUserId) const seenText = isLastMineMessage(visibleMessages, index, currentUserId)
? buildSeenText(participants, currentUserId) ? buildSeenText(participants, currentUserId, message)
: null : null
return ( return (
@@ -650,11 +968,66 @@ function summaryToReactionArray(summary, currentUserId) {
} }
function mergeMessageLists(existing, incoming) { function mergeMessageLists(existing, incoming) {
const map = new Map() const next = [...existing]
for (const message of [...existing, ...incoming]) {
map.set(message.id, message) for (const incomingMessage of incoming) {
const existingIndex = next.findIndex((message) => messagesMatch(message, incomingMessage))
if (existingIndex >= 0) {
next[existingIndex] = {
...next[existingIndex],
...incomingMessage,
_optimistic: false,
} }
return Array.from(map.values()).sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) continue
}
next.push(incomingMessage)
}
return next.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
}
function messagesMatch(left, right) {
if (!left || !right) return false
if (left.id && right.id && String(left.id) === String(right.id)) return true
if (left.uuid && right.uuid && left.uuid === right.uuid) return true
if (left.client_temp_id && right.client_temp_id && left.client_temp_id === right.client_temp_id) return true
return false
}
function mergeTypingUsers(existing, incoming) {
const matchIndex = existing.findIndex((user) => String(user.user_id ?? user.id) === String(incoming.user_id ?? incoming.id))
if (matchIndex === -1) {
return [...existing, incoming]
}
const next = [...existing]
next[matchIndex] = { ...next[matchIndex], ...incoming }
return next
}
function normalizePresenceUsers(users, currentUserId) {
return (users ?? []).filter((user) => user?.id !== currentUserId)
}
function mergePresenceUsers(existing, incoming, currentUserId) {
if (!incoming?.id || incoming.id === currentUserId) {
return existing
}
const next = [...existing]
const index = next.findIndex((user) => user.id === incoming.id)
if (index >= 0) {
next[index] = { ...next[index], ...incoming }
return next
}
next.push(incoming)
return next
}
function isNearBottom(container, threshold = 120) {
return container.scrollHeight - container.scrollTop - container.clientHeight <= threshold
} }
function buildTypingLabel(users) { function buildTypingLabel(users) {
@@ -674,29 +1047,38 @@ function isLastMineMessage(messages, index, currentUserId) {
return true return true
} }
function buildSeenText(participants, currentUserId) { function buildSeenText(participants, currentUserId, message) {
const seenBy = participants const seenBy = participants.filter((participant) => participant.user_id !== currentUserId && participantHasReadMessage(participant, message))
.filter((participant) => participant.user_id !== currentUserId && participant.last_read_at)
.map((participant) => participant.user?.username)
.filter(Boolean)
if (seenBy.length === 0) return 'Sent' if (seenBy.length === 0) return 'Sent'
if (seenBy.length === 1) return `Seen by @${seenBy[0]}`
if (seenBy.length === 1) {
const readAt = seenBy[0]?.last_read_at
return readAt ? `Seen ${formatSeenTime(readAt)}` : 'Seen'
}
return `Seen by ${seenBy.length} people` return `Seen by ${seenBy.length} people`
} }
function decorateMessages(messages, currentUserId, lastReadAt) { function decorateMessages(messages, currentUserId, participant) {
let unreadMarked = false let unreadMarked = false
const lastReadMessageId = Number(participant?.last_read_message_id ?? 0)
const lastReadAt = participant?.last_read_at ?? null
return messages.map((message, index) => { return messages.map((message, index) => {
const previous = messages[index - 1] const previous = messages[index - 1]
const currentDay = dayKey(message.created_at) const currentDay = dayKey(message.created_at)
const previousDay = previous ? dayKey(previous.created_at) : null const previousDay = previous ? dayKey(previous.created_at) : null
const shouldMarkUnread = !unreadMarked const shouldMarkUnread = !unreadMarked
&& !!lastReadAt
&& message.sender_id !== currentUserId && message.sender_id !== currentUserId
&& !message.deleted_at && !message.deleted_at
&& new Date(message.created_at).getTime() > new Date(lastReadAt).getTime() && (
lastReadMessageId > 0
? Number(message.id) > lastReadMessageId
: lastReadAt
? new Date(message.created_at).getTime() > new Date(lastReadAt).getTime()
: true
)
if (shouldMarkUnread) unreadMarked = true if (shouldMarkUnread) unreadMarked = true
@@ -708,6 +1090,26 @@ function decorateMessages(messages, currentUserId, lastReadAt) {
}) })
} }
function participantHasReadMessage(participant, message) {
const lastReadMessageId = Number(participant?.last_read_message_id ?? 0)
if (lastReadMessageId > 0) {
return Number(message?.id ?? 0) > 0 && lastReadMessageId >= Number(message.id)
}
if (participant?.last_read_at && message?.created_at) {
return new Date(participant.last_read_at).getTime() >= new Date(message.created_at).getTime()
}
return false
}
function formatSeenTime(iso) {
return new Date(iso).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
}
function dayKey(iso) { function dayKey(iso) {
const date = new Date(iso) const date = new Date(iso)
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
@@ -759,6 +1161,43 @@ function UnreadMarker({ prefersReducedMotion }) {
) )
} }
function TypingPulse() {
return (
<span className="inline-flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-emerald-300 animate-[pulse_1s_ease-in-out_infinite]" />
<span className="h-2 w-2 rounded-full bg-emerald-300/80 animate-[pulse_1s_ease-in-out_160ms_infinite]" />
<span className="h-2 w-2 rounded-full bg-emerald-300/60 animate-[pulse_1s_ease-in-out_320ms_infinite]" />
</span>
)
}
function RealtimeStatusBadge({ status }) {
const label = status === 'connected'
? 'Socket live'
: status === 'connecting'
? 'Socket connecting'
: 'Socket offline'
const tone = status === 'connected'
? 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200'
: status === 'connecting'
? 'border-amber-400/20 bg-amber-500/10 text-amber-200'
: 'border-rose-400/18 bg-rose-500/10 text-rose-200'
const dot = status === 'connected'
? 'bg-emerald-300'
: status === 'connecting'
? 'bg-amber-300'
: 'bg-rose-300'
return (
<span className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${tone}`}>
<span className={`h-1.5 w-1.5 rounded-full ${dot}`} />
{label}
</span>
)
}
function usePrefersReducedMotion() { function usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false) const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,100 @@ const SOCIAL_ICONS = {
website: { icon: 'fa-solid fa-link', label: 'Website' }, website: { icon: 'fa-solid fa-link', label: 'Website' },
} }
function formatNumber(value) {
return Number(value ?? 0).toLocaleString()
}
function formatRelativeDate(value) {
if (!value) return null
try {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
const now = new Date()
const diffSeconds = Math.round((date.getTime() - now.getTime()) / 1000)
const absSeconds = Math.abs(diffSeconds)
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
if (absSeconds < 3600) {
return formatter.format(Math.round(diffSeconds / 60), 'minute')
}
if (absSeconds < 86400) {
return formatter.format(Math.round(diffSeconds / 3600), 'hour')
}
if (absSeconds < 604800) {
return formatter.format(Math.round(diffSeconds / 86400), 'day')
}
if (absSeconds < 2629800) {
return formatter.format(Math.round(diffSeconds / 604800), 'week')
}
return formatter.format(Math.round(diffSeconds / 2629800), 'month')
} catch {
return null
}
}
function formatShortDate(value) {
if (!value) return null
try {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch {
return null
}
}
function truncateText(value, maxLength = 140) {
const text = String(value ?? '').trim()
if (!text) return ''
if (text.length <= maxLength) return text
return `${text.slice(0, maxLength).trimEnd()}...`
}
function buildInterestGroups(artworks = []) {
const categoryMap = new Map()
const contentTypeMap = new Map()
artworks.forEach((artwork) => {
const categoryKey = String(artwork?.category_slug || artwork?.category || '').trim().toLowerCase()
const categoryLabel = String(artwork?.category || '').trim()
const contentTypeKey = String(artwork?.content_type_slug || artwork?.content_type || '').trim().toLowerCase()
const contentTypeLabel = String(artwork?.content_type || '').trim()
if (categoryKey && categoryLabel) {
categoryMap.set(categoryKey, {
label: categoryLabel,
count: (categoryMap.get(categoryKey)?.count ?? 0) + 1,
})
}
if (contentTypeKey && contentTypeLabel) {
contentTypeMap.set(contentTypeKey, {
label: contentTypeLabel,
count: (contentTypeMap.get(contentTypeKey)?.count ?? 0) + 1,
})
}
})
const toSortedList = (source) => Array.from(source.values())
.sort((left, right) => right.count - left.count || left.label.localeCompare(right.label))
.slice(0, 5)
return {
categories: toSortedList(categoryMap),
contentTypes: toSortedList(contentTypeMap),
}
}
function InfoRow({ icon, label, children }) { function InfoRow({ icon, label, children }) {
return ( return (
<div className="flex items-start gap-3 py-2.5 border-b border-white/5 last:border-0"> <div className="flex items-start gap-3 py-2.5 border-b border-white/5 last:border-0">
@@ -22,11 +116,47 @@ function InfoRow({ icon, label, children }) {
) )
} }
function StatCard({ icon, label, value, tone = 'sky' }) {
const tones = {
sky: 'text-sky-300 bg-sky-400/10 border-sky-300/15',
amber: 'text-amber-200 bg-amber-300/10 border-amber-300/15',
emerald: 'text-emerald-200 bg-emerald-400/10 border-emerald-300/15',
violet: 'text-violet-200 bg-violet-400/10 border-violet-300/15',
}
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4 shadow-[0_18px_44px_rgba(2,6,23,0.18)]">
<div className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border ${tones[tone] || tones.sky}`}>
<i className={`fa-solid ${icon}`} />
</div>
<div className="mt-4 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
<div className="mt-1 text-2xl font-semibold tracking-tight text-white">{value}</div>
</div>
)
}
function SectionCard({ icon, eyebrow, title, children, className = '' }) {
return (
<section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_52px_rgba(2,6,23,0.18)] md:p-6 ${className}`.trim()}>
<div className="flex items-start gap-3">
<div className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.05] text-sky-300">
<i className={`${icon} text-base`} />
</div>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">{eyebrow}</p>
<h2 className="mt-1 text-xl font-semibold tracking-[-0.02em] text-white md:text-2xl">{title}</h2>
</div>
</div>
<div className="mt-5">{children}</div>
</section>
)
}
/** /**
* TabAbout * TabAbout
* Bio, social links, metadata - replaces old sidebar profile card. * Bio, social links, metadata - replaces old sidebar profile card.
*/ */
export default function TabAbout({ user, profile, socialLinks, countryName, followerCount }) { export default function TabAbout({ user, profile, stats, achievements, artworks, creatorStories, profileComments, socialLinks, countryName, followerCount, recentFollowers, leaderboardRank }) {
const uname = user.username || user.name const uname = user.username || user.name
const displayName = user.name || uname const displayName = user.name || uname
const about = profile?.about const about = profile?.about
@@ -47,71 +177,84 @@ export default function TabAbout({ user, profile, socialLinks, countryName, foll
const genderMap = { M: 'Male', F: 'Female', X: 'Non-binary / N/A' } const genderMap = { M: 'Male', F: 'Female', X: 'Non-binary / N/A' }
const genderLabel = genderMap[profile?.gender?.toUpperCase()] ?? null const genderLabel = genderMap[profile?.gender?.toUpperCase()] ?? null
const birthDate = profile?.birthdate
? (() => {
try {
return new Date(profile.birthdate).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
} catch { return null }
})()
: null
const lastSeenRelative = formatRelativeDate(user.last_visit_at)
const socialEntries = socialLinks const socialEntries = socialLinks
? Object.entries(socialLinks).filter(([, link]) => link?.url) ? Object.entries(socialLinks).filter(([, link]) => link?.url)
: [] : []
const followers = recentFollowers ?? []
const recentAchievements = Array.isArray(achievements?.recent) ? achievements.recent : []
const stories = Array.isArray(creatorStories) ? creatorStories : []
const comments = Array.isArray(profileComments) ? profileComments : []
const interestGroups = buildInterestGroups(Array.isArray(artworks) ? artworks : [])
const summaryCards = [
{ icon: 'fa-user-group', label: 'Followers', value: formatNumber(followerCount), tone: 'sky' },
{ icon: 'fa-images', label: 'Uploads', value: formatNumber(stats?.uploads_count ?? 0), tone: 'violet' },
{ icon: 'fa-eye', label: 'Profile views', value: formatNumber(stats?.profile_views_count ?? 0), tone: 'emerald' },
{ icon: 'fa-trophy', label: 'Weekly rank', value: leaderboardRank?.rank ? `#${formatNumber(leaderboardRank.rank)}` : 'Unranked', tone: 'amber' },
]
return ( return (
<div <div
id="tabpanel-about" id="tabpanel-about"
role="tabpanel" role="tabpanel"
aria-labelledby="tab-about" aria-labelledby="tab-about"
className="pt-6 max-w-2xl" className="mx-auto max-w-7xl px-4 pt-4 pb-10 md:px-6"
> >
{/* Bio */} <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{about ? ( {summaryCards.map((card) => (
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 mb-5 shadow-xl shadow-black/20 backdrop-blur"> <StatCard key={card.label} {...card} />
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2"> ))}
<i className="fa-solid fa-quote-left text-purple-400 fa-fw" />
About
</h2>
<p className="text-sm text-slate-300 leading-relaxed whitespace-pre-line">{about}</p>
</div> </div>
) : (
<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="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_380px]">
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 mb-5 shadow-xl shadow-black/20"> <div className="space-y-6">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2"> <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))]">
<i className="fa-solid fa-id-card text-sky-400 fa-fw" /> {about ? (
Profile Info <p className="whitespace-pre-line text-[15px] leading-8 text-slate-200/90">{about}</p>
</h2> ) : (
<div className="divide-y divide-white/5"> <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">
{displayName && displayName !== uname && ( 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> <InfoRow icon="fa-user" label="Display name">{displayName}</InfoRow>
)} ) : null}
<InfoRow icon="fa-at" label="Username"> <InfoRow icon="fa-at" label="Username"><span className="font-mono">@{uname}</span></InfoRow>
<span className="font-mono">@{uname}</span> {genderLabel ? <InfoRow icon="fa-venus-mars" label="Gender">{genderLabel}</InfoRow> : null}
</InfoRow> {countryName ? (
{genderLabel && (
<InfoRow icon="fa-venus-mars" label="Gender">{genderLabel}</InfoRow>
)}
{countryName && (
<InfoRow icon="fa-earth-americas" label="Country"> <InfoRow icon="fa-earth-americas" label="Country">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{profile?.country_code && ( {profile?.country_code ? (
<img <img
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`} src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
alt={countryName} alt={countryName}
className="w-4 h-auto rounded-sm" className="h-auto w-4 rounded-sm"
onError={(e) => { e.target.style.display = 'none' }} onError={(e) => { e.target.style.display = 'none' }}
/> />
)} ) : null}
{countryName} {countryName}
</span> </span>
</InfoRow> </InfoRow>
)} ) : null}
{website && ( {website ? (
<InfoRow icon="fa-link" label="Website"> <InfoRow icon="fa-link" label="Website">
<a <a
href={website.startsWith('http') ? website : `https://${website}`} href={website.startsWith('http') ? website : `https://${website}`}
target="_blank" target="_blank"
rel="nofollow noopener noreferrer" rel="nofollow noopener noreferrer"
className="text-sky-400 hover:text-sky-300 hover:underline transition-colors" className="text-sky-300 transition-colors hover:text-sky-200 hover:underline"
> >
{(() => { {(() => {
try { try {
@@ -121,35 +264,245 @@ export default function TabAbout({ user, profile, socialLinks, countryName, foll
})()} })()}
</a> </a>
</InfoRow> </InfoRow>
)} ) : null}
{joinDate && ( {birthDate ? <InfoRow icon="fa-cake-candles" label="Birth date">{birthDate}</InfoRow> : null}
<InfoRow icon="fa-calendar-days" label="Member since">{joinDate}</InfoRow> {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}
{lastVisit && ( </div>
<InfoRow icon="fa-clock" label="Last seen">{lastVisit}</InfoRow> </SectionCard>
)}
<InfoRow icon="fa-users" label="Followers">{Number(followerCount).toLocaleString()}</InfoRow> {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> </div>
{/* Social links */} <div className="grid gap-3 sm:grid-cols-2">
{socialEntries.length > 0 && ( <div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20"> <div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Weekly rank</div>
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2"> <div className="mt-2 text-2xl font-semibold tracking-tight text-white">{leaderboardRank?.rank ? `#${formatNumber(leaderboardRank.rank)}` : 'Not ranked'}</div>
<i className="fa-solid fa-share-nodes text-sky-400 fa-fw" /> {leaderboardRank?.score ? <div className="mt-1 text-sm text-slate-400">Score {formatNumber(leaderboardRank.score)}</div> : null}
Social Links </div>
</h2> <div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
<div className="flex flex-wrap gap-2"> <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]) => { {socialEntries.map(([platform, link]) => {
const si = SOCIAL_ICONS[platform] ?? { icon: 'fa-solid fa-link', label: platform } const si = SOCIAL_ICONS[platform] ?? { icon: 'fa-solid fa-link', label: platform }
const href = link.url.startsWith('http') ? link.url : `https://${link.url}` const href = link.url.startsWith('http') ? link.url : `https://${link.url}`
return ( return (
<a <a
key={platform} key={platform}
href={href} href={href}
target="_blank" target="_blank"
rel="nofollow noopener noreferrer" 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" 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} aria-label={si.label}
> >
<i className={`${si.icon} fa-fw`} /> <i className={`${si.icon} fa-fw`} />
@@ -158,8 +511,10 @@ export default function TabAbout({ user, profile, socialLinks, countryName, foll
) )
})} })}
</div> </div>
</SectionCard>
) : null}
</div>
</div> </div>
)}
</div> </div>
) )
} }

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