Compare commits
5 Commits
29c3ff8572
...
73260e7eae
| Author | SHA1 | Date | |
|---|---|---|---|
| 73260e7eae | |||
| 2608be7420 | |||
| e8b5edf5d2 | |||
| 60f78e8235 | |||
| 979e011257 |
74
.env.example
74
.env.example
@@ -41,9 +41,39 @@ SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
BROADCAST_CONNECTION=reverb
|
||||
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)
|
||||
SKINBASE_UPLOADS_V2=false
|
||||
@@ -57,6 +87,21 @@ SKINBASE_DUPLICATE_HASH_POLICY=block
|
||||
VISION_ENABLED=true
|
||||
VISION_QUEUE=default
|
||||
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_BASE_URL=
|
||||
@@ -81,6 +126,8 @@ RECOMMENDATIONS_AB_ALGO_VERSIONS=clip-cosine-v1
|
||||
RECOMMENDATIONS_MIN_DIM=64
|
||||
RECOMMENDATIONS_MAX_DIM=4096
|
||||
RECOMMENDATIONS_BACKFILL_BATCH=200
|
||||
SIMILARITY_VECTOR_ENABLED=false
|
||||
SIMILARITY_VECTOR_ADAPTER=pgvector
|
||||
|
||||
# Personalized discovery foundation (Phase 8)
|
||||
DISCOVERY_QUEUE=${RECOMMENDATIONS_QUEUE}
|
||||
@@ -94,6 +141,16 @@ DISCOVERY_WEIGHT_CLICK=2
|
||||
DISCOVERY_WEIGHT_FAVORITE=4
|
||||
DISCOVERY_WEIGHT_DOWNLOAD=3
|
||||
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_W1=0.65
|
||||
DISCOVERY_RANKING_W2=0.20
|
||||
@@ -145,6 +202,9 @@ YOLO_PHOTOGRAPHY_ONLY=true
|
||||
# VISION_ENABLED=true
|
||||
# VISION_QUEUE=vision
|
||||
# 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_ANALYZE_ENDPOINT=/analyze
|
||||
@@ -174,6 +234,16 @@ YOLO_PHOTOGRAPHY_ONLY=true
|
||||
# DISCOVERY_WEIGHT_CLICK=2
|
||||
# DISCOVERY_WEIGHT_FAVORITE=4
|
||||
# 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_W1=0.65
|
||||
# DISCOVERY_RANKING_W2=0.20
|
||||
|
||||
25
.gitignore
vendored
25
.gitignore
vendored
@@ -19,6 +19,31 @@
|
||||
/public/files
|
||||
/storage/*.key
|
||||
/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
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
|
||||
519
app/Console/Commands/AuditMigrationSchemaCommand.php
Normal file
519
app/Console/Commands/AuditMigrationSchemaCommand.php
Normal 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'])));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Support\Str;
|
||||
|
||||
class ImportLegacyUsers extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users} {--dry-run : Preview which users would be skipped/deleted without making changes}';
|
||||
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users} {--restore-temp-usernames : Restore legacy usernames for existing users still using tmpu12345-style placeholders} {--dry-run : Preview which users would be skipped/deleted without making changes}';
|
||||
protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec';
|
||||
|
||||
protected string $migrationLogPath;
|
||||
@@ -20,7 +20,7 @@ class ImportLegacyUsers extends Command
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->migrationLogPath = storage_path('logs/username_migration.log');
|
||||
$this->migrationLogPath = (string) storage_path('logs/username_migration.log');
|
||||
@file_put_contents($this->migrationLogPath, '['.now()."] Starting legacy username policy migration\n", FILE_APPEND);
|
||||
|
||||
// Build the set of legacy user IDs that have any meaningful activity.
|
||||
@@ -134,8 +134,14 @@ class ImportLegacyUsers extends Command
|
||||
{
|
||||
$legacyId = (int) $row->user_id;
|
||||
|
||||
// Use legacy username as-is (sanitized only, no numeric suffixing — was unique in old DB).
|
||||
$username = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId)));
|
||||
// Use legacy username as-is by default. Placeholder tmp usernames can be
|
||||
// restored explicitly with --restore-temp-usernames using safe uniqueness rules.
|
||||
$existingUser = DB::table('users')
|
||||
->select(['id', 'username'])
|
||||
->where('id', $legacyId)
|
||||
->first();
|
||||
|
||||
$username = $this->resolveImportUsername($row, $legacyId, $existingUser?->username ?? null);
|
||||
|
||||
$normalizedLegacy = UsernamePolicy::normalize((string) ($row->uname ?? ''));
|
||||
if ($normalizedLegacy !== $username) {
|
||||
@@ -173,7 +179,12 @@ class ImportLegacyUsers extends Command
|
||||
|
||||
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
|
||||
$now = now();
|
||||
$alreadyExists = DB::table('users')->where('id', $legacyId)->exists();
|
||||
$existingUser = DB::table('users')
|
||||
->select(['id', 'username'])
|
||||
->where('id', $legacyId)
|
||||
->first();
|
||||
$alreadyExists = $existingUser !== null;
|
||||
$previousUsername = (string) ($existingUser?->username ?? '');
|
||||
|
||||
// All fields synced from legacy on every run
|
||||
$sharedFields = [
|
||||
@@ -212,7 +223,7 @@ class ImportLegacyUsers extends Command
|
||||
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
|
||||
'language' => $row->lang ?: null,
|
||||
'birthdate' => $row->birth ?: null,
|
||||
'gender' => $row->gender ?: 'X',
|
||||
'gender' => $this->normalizeLegacyGender($row->gender ?? null),
|
||||
'website' => $row->web ?: null,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
@@ -232,7 +243,7 @@ class ImportLegacyUsers extends Command
|
||||
);
|
||||
|
||||
if (Schema::hasTable('username_redirects')) {
|
||||
$old = UsernamePolicy::normalize((string) ($row->uname ?? ''));
|
||||
$old = $this->usernameRedirectKey((string) ($row->uname ?? ''));
|
||||
if ($old !== '' && $old !== $username) {
|
||||
DB::table('username_redirects')->updateOrInsert(
|
||||
['old_username' => $old],
|
||||
@@ -244,10 +255,50 @@ class ImportLegacyUsers extends Command
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->shouldRestoreTemporaryUsername($previousUsername) && $previousUsername !== $username) {
|
||||
DB::table('username_redirects')->updateOrInsert(
|
||||
['old_username' => $this->usernameRedirectKey($previousUsername)],
|
||||
[
|
||||
'new_username' => $username,
|
||||
'user_id' => $legacyId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function resolveImportUsername(object $row, int $legacyId, ?string $existingUsername = null): string
|
||||
{
|
||||
$legacyUsername = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId)));
|
||||
|
||||
if (! $this->option('restore-temp-usernames')) {
|
||||
return $legacyUsername;
|
||||
}
|
||||
|
||||
if ($existingUsername === null || $existingUsername === '') {
|
||||
return $legacyUsername;
|
||||
}
|
||||
|
||||
if (! $this->shouldRestoreTemporaryUsername($existingUsername)) {
|
||||
return $existingUsername;
|
||||
}
|
||||
|
||||
return UsernamePolicy::uniqueCandidate((string) ($row->uname ?: ('user' . $legacyId)), $legacyId);
|
||||
}
|
||||
|
||||
protected function shouldRestoreTemporaryUsername(?string $username): bool
|
||||
{
|
||||
if (! is_string($username) || trim($username) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match('/^tmpu\d+$/i', trim($username)) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure statistic values are safe for unsigned DB columns.
|
||||
*/
|
||||
@@ -265,6 +316,24 @@ class ImportLegacyUsers extends Command
|
||||
return UsernamePolicy::sanitizeLegacy($username);
|
||||
}
|
||||
|
||||
protected function usernameRedirectKey(?string $username): string
|
||||
{
|
||||
$value = $this->sanitizeUsername((string) ($username ?? ''));
|
||||
|
||||
return $value === 'user' && trim((string) ($username ?? '')) === '' ? '' : $value;
|
||||
}
|
||||
|
||||
protected function normalizeLegacyGender(mixed $value): ?string
|
||||
{
|
||||
$normalized = strtoupper(trim((string) ($value ?? '')));
|
||||
|
||||
return match ($normalized) {
|
||||
'M', 'MALE', 'MAN', 'BOY' => 'M',
|
||||
'F', 'FEMALE', 'WOMAN', 'GIRL' => 'F',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
protected function sanitizeEmailLocal(string $value): string
|
||||
{
|
||||
$local = strtolower(trim($value));
|
||||
|
||||
184
app/Console/Commands/IndexArtworkVectorsCommand.php
Normal file
184
app/Console/Commands/IndexArtworkVectorsCommand.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Services\Vision\ArtworkVisionImageUrl;
|
||||
use App\Services\Vision\VectorGatewayClient;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class IndexArtworkVectorsCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:vectors-index
|
||||
{--start-id=0 : Start from this artwork id (inclusive)}
|
||||
{--after-id=0 : Resume after this artwork id}
|
||||
{--batch=100 : Batch size per iteration}
|
||||
{--limit=0 : Maximum artworks to process in this run}
|
||||
{--public-only : Index only public, approved, published artworks}
|
||||
{--dry-run : Preview requests without sending them}';
|
||||
|
||||
protected $description = 'Send artwork image URLs to the vector gateway for indexing';
|
||||
|
||||
public function handle(VectorGatewayClient $client, ArtworkVisionImageUrl $imageUrl): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
if (! $dryRun && ! $client->isConfigured()) {
|
||||
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$startId = max(0, (int) $this->option('start-id'));
|
||||
$afterId = max(0, (int) $this->option('after-id'));
|
||||
$batch = max(1, min((int) $this->option('batch'), 1000));
|
||||
$limit = max(0, (int) $this->option('limit'));
|
||||
$publicOnly = (bool) $this->option('public-only');
|
||||
$nextId = $startId > 0 ? $startId : max(1, $afterId + 1);
|
||||
|
||||
$processed = 0;
|
||||
$indexed = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
$lastId = $afterId;
|
||||
|
||||
if ($startId > 0 && $afterId > 0) {
|
||||
$this->warn(sprintf(
|
||||
'Both --start-id=%d and --after-id=%d were provided. Using --start-id and ignoring --after-id.',
|
||||
$startId,
|
||||
$afterId
|
||||
));
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Starting vector index: start_id=%d after_id=%d next_id=%d batch=%d limit=%s public_only=%s dry_run=%s',
|
||||
$startId,
|
||||
$afterId,
|
||||
$nextId,
|
||||
$batch,
|
||||
$limit > 0 ? (string) $limit : 'all',
|
||||
$publicOnly ? 'yes' : 'no',
|
||||
$dryRun ? 'yes' : 'no'
|
||||
));
|
||||
|
||||
while (true) {
|
||||
$remaining = $limit > 0 ? max(0, $limit - $processed) : $batch;
|
||||
if ($limit > 0 && $remaining === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
$take = $limit > 0 ? min($batch, $remaining) : $batch;
|
||||
|
||||
$query = Artwork::query()
|
||||
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
|
||||
->where('id', '>=', $nextId)
|
||||
->whereNotNull('hash')
|
||||
->orderBy('id')
|
||||
->limit($take);
|
||||
|
||||
if ($publicOnly) {
|
||||
$query->public()->published();
|
||||
}
|
||||
|
||||
$artworks = $query->get();
|
||||
if ($artworks->isEmpty()) {
|
||||
$this->line('No more artworks matched the current query window.');
|
||||
break;
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
'Fetched batch: count=%d first_id=%d last_id=%d',
|
||||
$artworks->count(),
|
||||
(int) $artworks->first()->id,
|
||||
(int) $artworks->last()->id
|
||||
));
|
||||
|
||||
foreach ($artworks as $artwork) {
|
||||
$processed++;
|
||||
$lastId = (int) $artwork->id;
|
||||
$nextId = $lastId + 1;
|
||||
|
||||
$url = $imageUrl->fromArtwork($artwork);
|
||||
if ($url === null) {
|
||||
$skipped++;
|
||||
$this->warn("Skipped artwork {$artwork->id}: no vision image URL could be generated.");
|
||||
continue;
|
||||
}
|
||||
|
||||
$metadata = $this->metadataForArtwork($artwork);
|
||||
$this->line(sprintf(
|
||||
'Processing artwork=%d hash=%s thumb_ext=%s url=%s metadata=%s',
|
||||
(int) $artwork->id,
|
||||
(string) ($artwork->hash ?? ''),
|
||||
(string) ($artwork->thumb_ext ?? ''),
|
||||
$url,
|
||||
$this->json($metadata)
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
$indexed++;
|
||||
$this->line(sprintf(
|
||||
'[dry] artwork=%d indexed=%d/%d',
|
||||
(int) $artwork->id,
|
||||
$indexed,
|
||||
$processed
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$client->upsertByUrl($url, (int) $artwork->id, $metadata);
|
||||
$indexed++;
|
||||
$this->info(sprintf(
|
||||
'Indexed artwork %d successfully. totals: processed=%d indexed=%d skipped=%d failed=%d',
|
||||
(int) $artwork->id,
|
||||
$processed,
|
||||
$indexed,
|
||||
$skipped,
|
||||
$failed
|
||||
));
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn("Failed artwork {$artwork->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Vector index finished. processed={$processed} indexed={$indexed} skipped={$skipped} failed={$failed} last_id={$lastId} next_id={$nextId}");
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $payload
|
||||
*/
|
||||
private function json(array $payload): string
|
||||
{
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return is_string($json) ? $json : '{}';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{content_type: string, category: string, user_id: string}
|
||||
*/
|
||||
private function metadataForArtwork(Artwork $artwork): array
|
||||
{
|
||||
$category = $this->primaryCategory($artwork);
|
||||
|
||||
return [
|
||||
'content_type' => (string) ($category?->contentType?->name ?? ''),
|
||||
'category' => (string) ($category?->name ?? ''),
|
||||
'user_id' => (string) ($artwork->user_id ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function primaryCategory(Artwork $artwork): ?Category
|
||||
{
|
||||
/** @var Category|null $category */
|
||||
$category = $artwork->categories->sortBy('sort_order')->first();
|
||||
|
||||
return $category;
|
||||
}
|
||||
}
|
||||
301
app/Console/Commands/RepairLegacyWallzUsersCommand.php
Normal file
301
app/Console/Commands/RepairLegacyWallzUsersCommand.php
Normal file
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class RepairLegacyWallzUsersCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:repair-legacy-wallz-users
|
||||
{--chunk=500 : Number of legacy wallz rows to scan per batch}
|
||||
{--legacy-connection=legacy : Legacy database connection name}
|
||||
{--legacy-table=wallz : Legacy table to update}
|
||||
{--artworks-table=artworks : Current DB artworks table name}
|
||||
{--fix-artworks : Backfill `artworks.user_id` from legacy `wallz.user_id` for rows where user_id = 0}
|
||||
{--dry-run : Preview matches and inserts without writing changes}';
|
||||
|
||||
protected $description = 'Backfill legacy wallz.user_id from uname by matching or creating users in the new users table';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$legacyConnection = (string) $this->option('legacy-connection');
|
||||
$legacyTable = (string) $this->option('legacy-table');
|
||||
$artworksTable = (string) $this->option('artworks-table');
|
||||
$fixArtworks = (bool) $this->option('fix-artworks');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if (! $this->legacyTableExists($legacyConnection, $legacyTable)) {
|
||||
$this->error("Legacy table {$legacyConnection}.{$legacyTable} does not exist or the connection is unavailable.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY RUN] No changes will be written.');
|
||||
}
|
||||
|
||||
if ($fixArtworks) {
|
||||
$this->handleFixArtworks($chunk, $legacyConnection, $legacyTable, $artworksTable, $dryRun);
|
||||
}
|
||||
|
||||
$total = (int) DB::connection($legacyConnection)
|
||||
->table($legacyTable)
|
||||
->where('user_id', 0)
|
||||
->count();
|
||||
|
||||
if ($total === 0) {
|
||||
if (! $fixArtworks) {
|
||||
$this->info('No legacy wallz rows with user_id = 0 were found.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Scanning {$total} legacy rows in {$legacyConnection}.{$legacyTable}.");
|
||||
|
||||
$processed = 0;
|
||||
$updatedRows = 0;
|
||||
$matchedUsers = 0;
|
||||
$createdUsers = 0;
|
||||
$skippedRows = 0;
|
||||
$usernameMap = [];
|
||||
|
||||
DB::connection($legacyConnection)
|
||||
->table($legacyTable)
|
||||
->select(['id', 'uname'])
|
||||
->where('user_id', 0)
|
||||
->orderBy('id')
|
||||
->chunkById($chunk, function ($rows) use (
|
||||
&$processed,
|
||||
&$updatedRows,
|
||||
&$matchedUsers,
|
||||
&$createdUsers,
|
||||
&$skippedRows,
|
||||
&$usernameMap,
|
||||
$dryRun,
|
||||
$legacyConnection,
|
||||
$legacyTable
|
||||
) {
|
||||
foreach ($rows as $row) {
|
||||
$processed++;
|
||||
|
||||
$rawUsername = trim((string) ($row->uname ?? ''));
|
||||
if ($rawUsername === '') {
|
||||
$skippedRows++;
|
||||
$this->warn("Skipping wallz id={$row->id}: uname is empty.");
|
||||
continue;
|
||||
}
|
||||
|
||||
$lookupKey = UsernamePolicy::normalize($rawUsername);
|
||||
if ($lookupKey === '') {
|
||||
$skippedRows++;
|
||||
$this->warn("Skipping wallz id={$row->id}: uname normalizes to empty.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! array_key_exists($lookupKey, $usernameMap)) {
|
||||
$existingUser = $this->findUserByUsername($lookupKey);
|
||||
|
||||
if ($existingUser !== null) {
|
||||
$usernameMap[$lookupKey] = [
|
||||
'user_id' => (int) $existingUser->id,
|
||||
'created' => false,
|
||||
];
|
||||
} else {
|
||||
$usernameMap[$lookupKey] = [
|
||||
'user_id' => $dryRun
|
||||
? 0
|
||||
: $this->createUserForLegacyUsername($rawUsername, $legacyConnection),
|
||||
'created' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$resolved = $usernameMap[$lookupKey];
|
||||
|
||||
if ($resolved['created']) {
|
||||
$createdUsers++;
|
||||
$usernameMap[$lookupKey]['created'] = false;
|
||||
$resolved['created'] = false;
|
||||
$this->line($dryRun
|
||||
? "[dry] Would create user for uname='{$rawUsername}'"
|
||||
: "[create] Created user_id={$usernameMap[$lookupKey]['user_id']} for uname='{$rawUsername}'");
|
||||
} else {
|
||||
$matchedUsers++;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$targetUser = $usernameMap[$lookupKey]['user_id'] > 0
|
||||
? (string) $usernameMap[$lookupKey]['user_id']
|
||||
: '<new-user-id>';
|
||||
$this->line("[dry] Would update wallz id={$row->id} to user_id={$targetUser} using uname='{$rawUsername}'");
|
||||
$updatedRows++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$affected = DB::connection($legacyConnection)
|
||||
->table($legacyTable)
|
||||
->where('id', $row->id)
|
||||
->where('user_id', 0)
|
||||
->update([
|
||||
'user_id' => $usernameMap[$lookupKey]['user_id'],
|
||||
]);
|
||||
|
||||
if ($affected > 0) {
|
||||
$updatedRows += $affected;
|
||||
}
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
$this->info(sprintf(
|
||||
'Finished. processed=%d updated=%d matched=%d created=%d skipped=%d',
|
||||
$processed,
|
||||
$updatedRows,
|
||||
$matchedUsers,
|
||||
$createdUsers,
|
||||
$skippedRows
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function handleFixArtworks(int $chunk, string $legacyConnection, string $legacyTable, string $artworksTable, bool $dryRun): void
|
||||
{
|
||||
$this->info("\nAttempting to backfill `{$artworksTable}.user_id` from legacy {$legacyConnection}.{$legacyTable} where user_id = 0");
|
||||
|
||||
$total = (int) DB::table($artworksTable)->where('user_id', 0)->count();
|
||||
$this->info("Found {$total} rows in {$artworksTable} with user_id = 0. Chunk size: {$chunk}.");
|
||||
|
||||
$processed = 0;
|
||||
$updated = 0;
|
||||
DB::table($artworksTable)
|
||||
->select(['id'])
|
||||
->where('user_id', 0)
|
||||
->orderBy('id')
|
||||
->chunkById($chunk, function ($rows) use (&$processed, &$updated, $legacyConnection, $legacyTable, $artworksTable, $dryRun) {
|
||||
foreach ($rows as $row) {
|
||||
$processed++;
|
||||
|
||||
$legacyUser = DB::connection($legacyConnection)
|
||||
->table($legacyTable)
|
||||
->where('id', $row->id)
|
||||
->value('user_id');
|
||||
|
||||
$legacyUser = (int) ($legacyUser ?? 0);
|
||||
if ($legacyUser <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line("[dry] Would update {$artworksTable} id={$row->id} to user_id={$legacyUser}");
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$affected = DB::table($artworksTable)
|
||||
->where('id', $row->id)
|
||||
->where('user_id', 0)
|
||||
->update(['user_id' => $legacyUser]);
|
||||
|
||||
if ($affected > 0) {
|
||||
$updated += $affected;
|
||||
}
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
$this->info(sprintf('Artworks backfill complete. processed=%d updated=%d', $processed, $updated));
|
||||
}
|
||||
|
||||
private function legacyTableExists(string $connection, string $table): bool
|
||||
{
|
||||
try {
|
||||
return DB::connection($connection)->getSchemaBuilder()->hasTable($table);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function findUserByUsername(string $normalizedUsername): ?object
|
||||
{
|
||||
return DB::table('users')
|
||||
->select(['id', 'username'])
|
||||
->whereRaw('LOWER(username) = ?', [$normalizedUsername])
|
||||
->first();
|
||||
}
|
||||
|
||||
private function createUserForLegacyUsername(string $legacyUsername, string $legacyConnection): int
|
||||
{
|
||||
$username = UsernamePolicy::uniqueCandidate($legacyUsername);
|
||||
$emailLocal = $this->sanitizeEmailLocal($username);
|
||||
$email = $this->uniqueEmailCandidate($emailLocal . '@users.skinbase.org');
|
||||
$now = now();
|
||||
|
||||
// Attempt to copy legacy joinDate from the legacy `users` table when available.
|
||||
$legacyJoin = null;
|
||||
try {
|
||||
$legacyJoin = DB::connection($legacyConnection)
|
||||
->table('users')
|
||||
->whereRaw('LOWER(uname) = ?', [strtolower((string) $legacyUsername)])
|
||||
->value('joinDate');
|
||||
} catch (\Throwable) {
|
||||
$legacyJoin = null;
|
||||
}
|
||||
|
||||
$createdAt = $now;
|
||||
if (! empty($legacyJoin) && strpos((string) $legacyJoin, '0000') !== 0) {
|
||||
try {
|
||||
$createdAt = Carbon::parse($legacyJoin);
|
||||
} catch (\Throwable) {
|
||||
$createdAt = $now;
|
||||
}
|
||||
}
|
||||
|
||||
$userId = (int) DB::table('users')->insertGetId([
|
||||
'username' => $username,
|
||||
'username_changed_at' => $now,
|
||||
'name' => $legacyUsername,
|
||||
'email' => $email,
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
'is_active' => true,
|
||||
'needs_password_reset' => true,
|
||||
'role' => 'user',
|
||||
'legacy_password_algo' => null,
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
private function uniqueEmailCandidate(string $email): string
|
||||
{
|
||||
$candidate = strtolower(trim($email));
|
||||
$suffix = 1;
|
||||
|
||||
while (DB::table('users')->whereRaw('LOWER(email) = ?', [$candidate])->exists()) {
|
||||
$parts = explode('@', $email, 2);
|
||||
$local = $parts[0] ?? 'user';
|
||||
$domain = $parts[1] ?? 'users.skinbase.org';
|
||||
$candidate = $local . '+' . $suffix . '@' . $domain;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function sanitizeEmailLocal(string $value): string
|
||||
{
|
||||
$local = strtolower(trim($value));
|
||||
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
|
||||
|
||||
return trim($local, '.-') ?: 'user';
|
||||
}
|
||||
}
|
||||
135
app/Console/Commands/RepairTemporaryUsernamesCommand.php
Normal file
135
app/Console/Commands/RepairTemporaryUsernamesCommand.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RepairTemporaryUsernamesCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:repair-temp-usernames
|
||||
{--chunk=500 : Number of users to process per batch}
|
||||
{--dry-run : Preview username changes without writing them}';
|
||||
|
||||
protected $description = 'Replace current users.username values like tmpu% using the users.name field';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY RUN] No changes will be written.');
|
||||
}
|
||||
|
||||
$total = (int) DB::table('users')
|
||||
->where('username', 'like', 'tmpu%')
|
||||
->count();
|
||||
|
||||
if ($total === 0) {
|
||||
$this->info('No users with temporary tmpu% usernames were found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$total} users with temporary tmpu% usernames.");
|
||||
|
||||
$processed = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
DB::table('users')
|
||||
->select(['id', 'name', 'username'])
|
||||
->where('username', 'like', 'tmpu%')
|
||||
->chunkById($chunk, function ($rows) use (&$processed, &$updated, &$skipped, $dryRun) {
|
||||
foreach ($rows as $row) {
|
||||
$processed++;
|
||||
|
||||
$sourceName = trim((string) ($row->name ?? ''));
|
||||
if ($sourceName === '') {
|
||||
$skipped++;
|
||||
$this->warn("Skipping user id={$row->id}: name is empty.");
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidate = $this->resolveCandidate($sourceName, (int) $row->id);
|
||||
|
||||
if ($candidate === null || strcasecmp($candidate, (string) $row->username) === 0) {
|
||||
$skipped++;
|
||||
$this->warn("Skipping user id={$row->id}: unable to resolve a better username from name='{$sourceName}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line("[dry] Would update user id={$row->id} username '{$row->username}' => '{$candidate}'");
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$affected = DB::table('users')
|
||||
->where('id', (int) $row->id)
|
||||
->where('username', 'like', 'tmpu%')
|
||||
->update([
|
||||
'username' => $candidate,
|
||||
'username_changed_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if ($affected > 0) {
|
||||
$updated += $affected;
|
||||
$this->line("[update] user id={$row->id} username '{$row->username}' => '{$candidate}'");
|
||||
}
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
$this->info(sprintf('Finished. processed=%d updated=%d skipped=%d', $processed, $updated, $skipped));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveCandidate(string $sourceName, int $userId): ?string
|
||||
{
|
||||
$base = UsernamePolicy::sanitizeLegacy($sourceName);
|
||||
$min = UsernamePolicy::min();
|
||||
$max = UsernamePolicy::max();
|
||||
|
||||
if ($base === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^tmpu\d+$/i', $base) === 1) {
|
||||
$base = 'user' . $userId;
|
||||
}
|
||||
|
||||
if (strlen($base) < $min) {
|
||||
$base = substr($base . $userId, 0, $max);
|
||||
}
|
||||
|
||||
if ($base === '' || $base === 'user') {
|
||||
$base = 'user' . $userId;
|
||||
}
|
||||
|
||||
$candidate = substr($base, 0, $max);
|
||||
$suffix = 1;
|
||||
|
||||
while ($this->usernameExists($candidate, $userId) || UsernamePolicy::isReserved($candidate)) {
|
||||
$suffixValue = (string) $suffix;
|
||||
$prefixLen = max(1, $max - strlen($suffixValue));
|
||||
$candidate = substr($base, 0, $prefixLen) . $suffixValue;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function usernameExists(string $username, int $ignoreUserId): bool
|
||||
{
|
||||
return DB::table('users')
|
||||
->whereRaw('LOWER(username) = ?', [strtolower($username)])
|
||||
->where('id', '!=', $ignoreUserId)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
118
app/Console/Commands/SearchArtworkVectorsCommand.php
Normal file
118
app/Console/Commands/SearchArtworkVectorsCommand.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Services\Vision\ArtworkVisionImageUrl;
|
||||
use App\Services\Vision\VectorGatewayClient;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class SearchArtworkVectorsCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:vectors-search
|
||||
{artwork_id : Source artwork id}
|
||||
{--limit=5 : Number of similar artworks to return}';
|
||||
|
||||
protected $description = 'Search similar artworks through the vector gateway using an artwork image URL';
|
||||
|
||||
public function handle(VectorGatewayClient $client, ArtworkVisionImageUrl $imageUrl): int
|
||||
{
|
||||
if (! $client->isConfigured()) {
|
||||
$this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$artworkId = max(1, (int) $this->argument('artwork_id'));
|
||||
$limit = max(1, min((int) $this->option('limit'), 100));
|
||||
|
||||
$artwork = Artwork::query()
|
||||
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
|
||||
->find($artworkId);
|
||||
|
||||
if (! $artwork) {
|
||||
$this->error("Artwork {$artworkId} was not found.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$url = $imageUrl->fromArtwork($artwork);
|
||||
if ($url === null) {
|
||||
$this->error("Artwork {$artworkId} does not have a usable CDN image URL.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$matches = $client->searchByUrl($url, $limit + 1);
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Vector search failed: ' . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$ids = collect($matches)
|
||||
->map(fn (array $match): int => (int) $match['id'])
|
||||
->filter(fn (int $id): bool => $id > 0 && $id !== $artworkId)
|
||||
->unique()
|
||||
->take($limit)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($ids === []) {
|
||||
$this->warn('No similar artworks were returned by the vector gateway.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$artworks = Artwork::query()
|
||||
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
|
||||
->whereIn('id', $ids)
|
||||
->public()
|
||||
->published()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$rows = [];
|
||||
foreach ($matches as $match) {
|
||||
$matchId = (int) ($match['id'] ?? 0);
|
||||
if ($matchId <= 0 || $matchId === $artworkId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @var Artwork|null $matchedArtwork */
|
||||
$matchedArtwork = $artworks->get($matchId);
|
||||
if (! $matchedArtwork) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$category = $this->primaryCategory($matchedArtwork);
|
||||
$rows[] = [
|
||||
'id' => $matchId,
|
||||
'score' => number_format((float) ($match['score'] ?? 0.0), 4, '.', ''),
|
||||
'title' => (string) $matchedArtwork->title,
|
||||
'content_type' => (string) ($category?->contentType?->name ?? ''),
|
||||
'category' => (string) ($category?->name ?? ''),
|
||||
];
|
||||
|
||||
if (count($rows) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
$this->warn('The vector gateway returned matches, but none resolved to public published artworks.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(['ID', 'Score', 'Title', 'Content Type', 'Category'], $rows);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function primaryCategory(Artwork $artwork): ?Category
|
||||
{
|
||||
/** @var Category|null $category */
|
||||
$category = $artwork->categories->sortBy('sort_order')->first();
|
||||
|
||||
return $category;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ use App\Console\Commands\ImportLegacyUsers;
|
||||
use App\Console\Commands\ImportCategories;
|
||||
use App\Console\Commands\MigrateFeaturedWorks;
|
||||
use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
|
||||
use App\Console\Commands\IndexArtworkVectorsCommand;
|
||||
use App\Console\Commands\SearchArtworkVectorsCommand;
|
||||
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
|
||||
use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
||||
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
|
||||
@@ -43,6 +45,8 @@ class Kernel extends ConsoleKernel
|
||||
CleanupUploadsCommand::class,
|
||||
PublishScheduledArtworksCommand::class,
|
||||
BackfillArtworkEmbeddingsCommand::class,
|
||||
IndexArtworkVectorsCommand::class,
|
||||
SearchArtworkVectorsCommand::class,
|
||||
AggregateSimilarArtworkAnalyticsCommand::class,
|
||||
AggregateFeedAnalyticsCommand::class,
|
||||
AggregateTagInteractionAnalyticsCommand::class,
|
||||
|
||||
50
app/Events/ConversationUpdated.php
Normal file
50
app/Events/ConversationUpdated.php
Normal 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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Events/MessageCreated.php
Normal file
51
app/Events/MessageCreated.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
45
app/Events/MessageDeleted.php
Normal file
45
app/Events/MessageDeleted.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Events/MessageRead.php
Normal file
51
app/Events/MessageRead.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
44
app/Events/MessageUpdated.php
Normal file
44
app/Events/MessageUpdated.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,46 @@
|
||||
|
||||
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\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 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,45 @@
|
||||
|
||||
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\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 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,7 @@ class ArtworkCommentController extends Controller
|
||||
'id' => $c->id,
|
||||
'parent_id' => $c->parent_id,
|
||||
'raw_content' => $c->raw_content ?? $c->content,
|
||||
'rendered_content' => $c->rendered_content ?? e(strip_tags($c->content ?? '')),
|
||||
'rendered_content' => $this->renderCommentContent($c),
|
||||
'created_at' => $c->created_at?->toIso8601String(),
|
||||
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
||||
'can_edit' => $currentUserId === $userId,
|
||||
@@ -224,6 +224,31 @@ class ArtworkCommentController extends Controller
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function renderCommentContent(ArtworkComment $comment): string
|
||||
{
|
||||
$rawContent = (string) ($comment->raw_content ?? $comment->content ?? '');
|
||||
$renderedContent = $comment->rendered_content;
|
||||
|
||||
if (! is_string($renderedContent) || trim($renderedContent) === '') {
|
||||
$renderedContent = $rawContent !== ''
|
||||
? ContentSanitizer::render($rawContent)
|
||||
: nl2br(e(strip_tags((string) ($comment->content ?? ''))));
|
||||
}
|
||||
|
||||
return ContentSanitizer::sanitizeRenderedHtml(
|
||||
$renderedContent,
|
||||
$this->commentAuthorCanPublishLinks($comment)
|
||||
);
|
||||
}
|
||||
|
||||
private function commentAuthorCanPublishLinks(ArtworkComment $comment): bool
|
||||
{
|
||||
$level = (int) ($comment->user?->level ?? 1);
|
||||
$rank = strtolower((string) ($comment->user?->rank ?? 'Newbie'));
|
||||
|
||||
return $level > 1 && $rank !== 'newbie';
|
||||
}
|
||||
|
||||
private function notifyRecipients(Artwork $artwork, ArtworkComment $comment, User $actor, ?int $parentId): void
|
||||
{
|
||||
$notifiedUserIds = [];
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Events\ConversationUpdated;
|
||||
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\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
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\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -16,6 +22,13 @@ use Illuminate\Support\Facades\Schema;
|
||||
|
||||
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 ─────────────────────────────────────
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
@@ -26,24 +39,14 @@ class ConversationController extends Controller
|
||||
$cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion);
|
||||
|
||||
$conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) {
|
||||
return Conversation::query()
|
||||
$query = Conversation::query()
|
||||
->select('conversations.*')
|
||||
->join('conversation_participants as cp_me', function ($join) use ($user) {
|
||||
$join->on('cp_me.conversation_id', '=', 'conversations.id')
|
||||
->where('cp_me.user_id', '=', $user->id)
|
||||
->whereNull('cp_me.left_at');
|
||||
})
|
||||
->addSelect([
|
||||
'unread_count' => Message::query()
|
||||
->selectRaw('count(*)')
|
||||
->whereColumn('messages.conversation_id', 'conversations.id')
|
||||
->where('messages.sender_id', '!=', $user->id)
|
||||
->whereNull('messages.deleted_at')
|
||||
->where(function ($query) {
|
||||
$query->whereNull('cp_me.last_read_at')
|
||||
->orWhereColumn('messages.created_at', '>', 'cp_me.last_read_at');
|
||||
}),
|
||||
])
|
||||
->where('conversations.is_active', true)
|
||||
->with([
|
||||
'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']),
|
||||
'latestMessage.sender:id,username',
|
||||
@@ -51,8 +54,11 @@ class ConversationController extends Controller
|
||||
->orderByDesc('cp_me.is_pinned')
|
||||
->orderByDesc('cp_me.pinned_at')
|
||||
->orderByDesc('last_message_at')
|
||||
->orderByDesc('conversations.id')
|
||||
->paginate(20, ['conversations.*'], 'page', $page);
|
||||
->orderByDesc('conversations.id');
|
||||
|
||||
$this->unreadCounters->applyUnreadCountSelect($query, $user, 'cp_me');
|
||||
|
||||
return $query->paginate(20, ['conversations.*'], 'page', $page);
|
||||
});
|
||||
|
||||
$conversations->through(function ($conv) use ($user) {
|
||||
@@ -61,7 +67,12 @@ class ConversationController extends Controller
|
||||
return $conv;
|
||||
});
|
||||
|
||||
return response()->json($conversations);
|
||||
return response()->json([
|
||||
...$conversations->toArray(),
|
||||
'summary' => [
|
||||
'unread_total' => $this->unreadCounters->totalUnreadForUser($user),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── GET /api/messages/conversation/{id} ─────────────────────────────────
|
||||
@@ -80,18 +91,10 @@ class ConversationController extends Controller
|
||||
|
||||
// ── POST /api/messages/conversation ─────────────────────────────────────
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
public function store(StoreConversationRequest $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$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',
|
||||
]);
|
||||
$data = $request->validated();
|
||||
|
||||
if ($data['type'] === 'direct') {
|
||||
return $this->createDirect($request, $user, $data);
|
||||
@@ -104,20 +107,29 @@ class ConversationController extends Controller
|
||||
|
||||
public function markRead(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participant->update(['last_read_at' => now()]);
|
||||
$this->touchConversationCachesForUsers([$request->user()->id]);
|
||||
$conversation = $this->findAuthorized($request, $id);
|
||||
$participant = $this->conversationReads->markConversationRead(
|
||||
$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 ─────────────────────────
|
||||
|
||||
public function archive(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conversation = $this->findAuthorized($request, $id);
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$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]);
|
||||
}
|
||||
@@ -126,27 +138,30 @@ class ConversationController extends Controller
|
||||
|
||||
public function mute(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conversation = $this->findAuthorized($request, $id);
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$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]);
|
||||
}
|
||||
|
||||
public function pin(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conversation = $this->findAuthorized($request, $id);
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participant->update(['is_pinned' => true, 'pinned_at' => now()]);
|
||||
$this->touchConversationCachesForUsers([$request->user()->id]);
|
||||
$this->broadcastConversationUpdate($conversation, 'conversation.pinned');
|
||||
|
||||
return response()->json(['is_pinned' => true]);
|
||||
}
|
||||
|
||||
public function unpin(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$conversation = $this->findAuthorized($request, $id);
|
||||
$participant = $this->participantRecord($request, $id);
|
||||
$participant->update(['is_pinned' => false, 'pinned_at' => null]);
|
||||
$this->touchConversationCachesForUsers([$request->user()->id]);
|
||||
$this->broadcastConversationUpdate($conversation, 'conversation.unpinned');
|
||||
|
||||
return response()->json(['is_pinned' => false]);
|
||||
}
|
||||
@@ -182,14 +197,15 @@ class ConversationController extends Controller
|
||||
}
|
||||
|
||||
$participant->update(['left_at' => now()]);
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
|
||||
$this->broadcastConversationUpdate($conv, 'conversation.left', $participantUserIds);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
$this->requireAdmin($request, $id);
|
||||
@@ -198,9 +214,7 @@ class ConversationController extends Controller
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
|
||||
$data = $request->validate([
|
||||
'user_id' => 'required|integer|exists:users,id',
|
||||
]);
|
||||
$data = $request->validated();
|
||||
|
||||
$existing = ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $data['user_id'])
|
||||
@@ -220,20 +234,18 @@ class ConversationController extends Controller
|
||||
}
|
||||
|
||||
$participantUserIds[] = (int) $data['user_id'];
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
|
||||
$this->broadcastConversationUpdate($conv, 'conversation.participant_added', $participantUserIds);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
|
||||
$data = $request->validate([
|
||||
'user_id' => 'required|integer',
|
||||
]);
|
||||
$data = $request->validated();
|
||||
|
||||
// Cannot remove the conversation creator
|
||||
$conv = Conversation::findOrFail($id);
|
||||
@@ -263,26 +275,28 @@ class ConversationController extends Controller
|
||||
->whereNull('left_at')
|
||||
->update(['left_at' => now()]);
|
||||
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
|
||||
$this->broadcastConversationUpdate($conv, 'conversation.participant_removed', $participantUserIds);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
abort_unless($conv->isGroup(), 422, 'Only group conversations can be renamed.');
|
||||
$this->requireAdmin($request, $id);
|
||||
|
||||
$data = $request->validate(['title' => 'required|string|max:120']);
|
||||
$data = $request->validated();
|
||||
$conv->update(['title' => $data['title']]);
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
|
||||
$this->broadcastConversationUpdate($conv, 'conversation.renamed', $participantUserIds);
|
||||
|
||||
return response()->json(['title' => $conv->title]);
|
||||
}
|
||||
@@ -307,8 +321,10 @@ class ConversationController extends Controller
|
||||
if (! $conv) {
|
||||
$conv = DB::transaction(function () use ($user, $recipient) {
|
||||
$conv = Conversation::create([
|
||||
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||
'type' => 'direct',
|
||||
'created_by' => $user->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
ConversationParticipant::insert([
|
||||
@@ -320,17 +336,12 @@ class ConversationController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
// Insert first / next message
|
||||
$message = $conv->messages()->create([
|
||||
'sender_id' => $user->id,
|
||||
'body' => $data['body'],
|
||||
$this->sendMessage->execute($conv, $user, [
|
||||
'body' => $data['body'],
|
||||
'client_temp_id' => $data['client_temp_id'] ?? null,
|
||||
]);
|
||||
|
||||
$conv->update(['last_message_at' => $message->created_at]);
|
||||
app(MessageNotificationService::class)->notifyNewMessage($conv, $message, $user);
|
||||
$this->touchConversationCachesForUsers([$user->id, $recipient->id]);
|
||||
|
||||
return response()->json($conv->load('allParticipants.user:id,username'), 201);
|
||||
return response()->json($conv->fresh()->load('allParticipants.user:id,username'), 201);
|
||||
}
|
||||
|
||||
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 = Conversation::create([
|
||||
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||
'type' => 'group',
|
||||
'title' => $data['title'],
|
||||
'created_by' => $user->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$rows = array_map(fn ($uid) => [
|
||||
@@ -353,27 +366,21 @@ class ConversationController extends Controller
|
||||
|
||||
ConversationParticipant::insert($rows);
|
||||
|
||||
$message = $conv->messages()->create([
|
||||
'sender_id' => $user->id,
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
$conv->update(['last_message_at' => $message->created_at]);
|
||||
|
||||
return [$conv, $message];
|
||||
return $conv;
|
||||
});
|
||||
|
||||
[$conversation, $message] = $conv;
|
||||
app(MessageNotificationService::class)->notifyNewMessage($conversation, $message, $user);
|
||||
$this->touchConversationCachesForUsers($participantIds);
|
||||
$this->sendMessage->execute($conv, $user, [
|
||||
'body' => $data['body'],
|
||||
'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
|
||||
{
|
||||
$conv = Conversation::findOrFail($id);
|
||||
$this->assertParticipant($request, $id);
|
||||
$this->authorize('view', $conv);
|
||||
return $conv;
|
||||
}
|
||||
|
||||
@@ -399,28 +406,13 @@ class ConversationController extends Controller
|
||||
|
||||
private function requireAdmin(Request $request, int $id): void
|
||||
{
|
||||
abort_unless(
|
||||
ConversationParticipant::where('conversation_id', $id)
|
||||
->where('user_id', $request->user()->id)
|
||||
->where('role', 'admin')
|
||||
->whereNull('left_at')
|
||||
->exists(),
|
||||
403,
|
||||
'Only admins can perform this action.'
|
||||
);
|
||||
$conversation = Conversation::findOrFail($id);
|
||||
$this->authorize('manageParticipants', $conversation);
|
||||
}
|
||||
|
||||
private function touchConversationCachesForUsers(array $userIds): void
|
||||
{
|
||||
foreach (array_unique($userIds) as $userId) {
|
||||
if (! $userId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$versionKey = $this->cacheVersionKey((int) $userId);
|
||||
Cache::add($versionKey, 1, now()->addDay());
|
||||
Cache::increment($versionKey);
|
||||
}
|
||||
$this->conversationState->touchConversationCachesForUsers($userIds);
|
||||
}
|
||||
|
||||
private function cacheVersionKey(int $userId): string
|
||||
@@ -433,6 +425,16 @@ class ConversationController extends Controller
|
||||
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
|
||||
{
|
||||
if (! Schema::hasTable('user_blocks')) {
|
||||
|
||||
@@ -2,31 +2,53 @@
|
||||
|
||||
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\Requests\Messaging\StoreMessageRequest;
|
||||
use App\Http\Requests\Messaging\ToggleMessageReactionRequest;
|
||||
use App\Http\Requests\Messaging\UpdateMessageRequest;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\MessageAttachment;
|
||||
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\MessageNotificationService;
|
||||
use App\Services\Messaging\SendMessageAction;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MessageController extends Controller
|
||||
{
|
||||
private const PAGE_SIZE = 30;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConversationDeltaService $conversationDelta,
|
||||
private readonly ConversationStateService $conversationState,
|
||||
private readonly MessagingPayloadFactory $payloadFactory,
|
||||
private readonly SendMessageAction $sendMessage,
|
||||
) {}
|
||||
|
||||
// ── GET /api/messages/{conversation_id} ──────────────────────────────────
|
||||
|
||||
public function index(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->assertParticipant($request, $conversationId);
|
||||
$cursor = $request->integer('cursor');
|
||||
$conversation = $this->findConversationOrFail($conversationId);
|
||||
$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()
|
||||
->where('conversation_id', $conversationId)
|
||||
@@ -44,65 +66,45 @@ class MessageController extends Controller
|
||||
$nextCursor = $hasMore && $messages->isNotEmpty() ? (int) $messages->first()->id : null;
|
||||
|
||||
return response()->json([
|
||||
'data' => $messages,
|
||||
'data' => $messages->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $request->user()->id))->values(),
|
||||
'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} ─────────────────────────────────
|
||||
|
||||
public function store(Request $request, int $conversationId): JsonResponse
|
||||
public function store(StoreMessageRequest $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->assertParticipant($request, $conversationId);
|
||||
|
||||
$data = $request->validate([
|
||||
'body' => 'nullable|string|max:5000',
|
||||
'attachments' => 'sometimes|array|max:5',
|
||||
'attachments.*' => 'file|max:25600',
|
||||
]);
|
||||
$conversation = $this->findConversationOrFail($conversationId);
|
||||
$data = $request->validated();
|
||||
$data['attachments'] = $request->file('attachments', []);
|
||||
|
||||
$body = trim((string) ($data['body'] ?? ''));
|
||||
$files = $request->file('attachments', []);
|
||||
abort_if($body === '' && empty($files), 422, 'Message body or attachment is required.');
|
||||
abort_if($body === '' && empty($data['attachments']), 422, 'Message body or attachment is required.');
|
||||
|
||||
$message = Message::create([
|
||||
'conversation_id' => $conversationId,
|
||||
'sender_id' => $request->user()->id,
|
||||
'body' => $body,
|
||||
]);
|
||||
$message = $this->sendMessage->execute($conversation, $request->user(), $data);
|
||||
|
||||
foreach ($files as $file) {
|
||||
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);
|
||||
return response()->json($this->payloadFactory->message($message, (int) $request->user()->id), 201);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
|
||||
$data = $request->validate(['reaction' => 'required|string|max:32']);
|
||||
$this->findConversationOrFail($conversationId);
|
||||
$data = $request->validated();
|
||||
$this->assertAllowedReaction($data['reaction']);
|
||||
|
||||
$existing = MessageReaction::where([
|
||||
@@ -126,11 +128,10 @@ class MessageController extends Controller
|
||||
|
||||
// ── 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);
|
||||
|
||||
$data = $request->validate(['reaction' => 'required|string|max:32']);
|
||||
$this->findConversationOrFail($conversationId);
|
||||
$data = $request->validated();
|
||||
$this->assertAllowedReaction($data['reaction']);
|
||||
|
||||
MessageReaction::where([
|
||||
@@ -142,12 +143,11 @@ class MessageController extends Controller
|
||||
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);
|
||||
$this->assertParticipant($request, (int) $message->conversation_id);
|
||||
|
||||
$data = $request->validate(['reaction' => 'required|string|max:32']);
|
||||
$this->findConversationOrFail((int) $message->conversation_id);
|
||||
$data = $request->validated();
|
||||
$this->assertAllowedReaction($data['reaction']);
|
||||
|
||||
$existing = MessageReaction::where([
|
||||
@@ -169,12 +169,11 @@ class MessageController extends Controller
|
||||
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);
|
||||
$this->assertParticipant($request, (int) $message->conversation_id);
|
||||
|
||||
$data = $request->validate(['reaction' => 'required|string|max:32']);
|
||||
$this->findConversationOrFail((int) $message->conversation_id);
|
||||
$data = $request->validated();
|
||||
$this->assertAllowedReaction($data['reaction']);
|
||||
|
||||
MessageReaction::where([
|
||||
@@ -188,19 +187,15 @@ class MessageController extends Controller
|
||||
|
||||
// ── 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);
|
||||
|
||||
abort_unless(
|
||||
$message->sender_id === $request->user()->id,
|
||||
403,
|
||||
'You may only edit your own messages.'
|
||||
);
|
||||
$this->authorize('update', $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([
|
||||
'body' => $data['body'],
|
||||
@@ -208,13 +203,21 @@ class MessageController extends Controller
|
||||
]);
|
||||
app(MessageSearchIndexer::class)->updateMessage($message);
|
||||
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
$this->touchConversationCachesForUsers($participantUserIds);
|
||||
$participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id);
|
||||
$this->conversationState->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} ──────────────────────────────
|
||||
@@ -223,19 +226,24 @@ class MessageController extends Controller
|
||||
{
|
||||
$message = Message::findOrFail($messageId);
|
||||
|
||||
abort_unless(
|
||||
$message->sender_id === $request->user()->id || $request->user()->isAdmin(),
|
||||
403,
|
||||
'You may only delete your own messages.'
|
||||
);
|
||||
$this->authorize('delete', $message);
|
||||
|
||||
$participantUserIds = ConversationParticipant::where('conversation_id', $message->conversation_id)
|
||||
->whereNull('left_at')
|
||||
->pluck('user_id')
|
||||
->all();
|
||||
$participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id);
|
||||
app(MessageSearchIndexer::class)->deleteMessage($message);
|
||||
$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]);
|
||||
}
|
||||
@@ -256,15 +264,7 @@ class MessageController extends Controller
|
||||
|
||||
private 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);
|
||||
}
|
||||
$this->conversationState->touchConversationCachesForUsers($userIds);
|
||||
}
|
||||
|
||||
private function assertAllowedReaction(string $reaction): void
|
||||
@@ -298,54 +298,11 @@ class MessageController extends Controller
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function storeAttachment(UploadedFile $file, Message $message, int $userId): void
|
||||
private function findConversationOrFail(int $conversationId): Conversation
|
||||
{
|
||||
$mime = (string) $file->getMimeType();
|
||||
$finfoMime = (string) finfo_file(finfo_open(FILEINFO_MIME_TYPE), $file->getPathname());
|
||||
$detectedMime = $finfoMime !== '' ? $finfoMime : $mime;
|
||||
$conversation = Conversation::query()->findOrFail($conversationId);
|
||||
$this->authorize('view', $conversation);
|
||||
|
||||
$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,
|
||||
'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(),
|
||||
]);
|
||||
return $conversation;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,18 +71,12 @@ class MessageSearchController extends Controller
|
||||
|
||||
$hits = collect($result->getHits() ?? []);
|
||||
$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();
|
||||
$hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]);
|
||||
if ($hits->isEmpty()) {
|
||||
[$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();
|
||||
|
||||
@@ -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
|
||||
{
|
||||
abort_unless($request->user()?->isAdmin(), 403, 'Admin access required.');
|
||||
|
||||
@@ -16,9 +16,13 @@ class MessagingSettingsController extends Controller
|
||||
{
|
||||
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([
|
||||
'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone',
|
||||
'realtime_enabled' => (bool) config('messaging.realtime', false),
|
||||
'realtime_enabled' => $realtimeReady,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
33
app/Http/Controllers/Api/Messaging/PresenceController.php
Normal file
33
app/Http/Controllers/Api/Messaging/PresenceController.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Messaging;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Conversation;
|
||||
use App\Services\Messaging\MessagingPresenceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PresenceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessagingPresenceService $presence,
|
||||
) {}
|
||||
|
||||
public function heartbeat(Request $request): JsonResponse
|
||||
{
|
||||
$conversationId = $request->integer('conversation_id') ?: null;
|
||||
|
||||
if ($conversationId) {
|
||||
$conversation = Conversation::query()->findOrFail($conversationId);
|
||||
$this->authorize('view', $conversation);
|
||||
}
|
||||
|
||||
$this->presence->touch($request->user(), $conversationId);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'conversation_id' => $conversationId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Messaging;
|
||||
use App\Events\TypingStarted;
|
||||
use App\Events\TypingStopped;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use Illuminate\Cache\Repository;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -15,13 +16,13 @@ class TypingController extends Controller
|
||||
{
|
||||
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));
|
||||
$this->store()->put($this->key($conversationId, (int) $request->user()->id), 1, now()->addSeconds($ttl));
|
||||
|
||||
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]);
|
||||
@@ -29,11 +30,11 @@ class TypingController extends Controller
|
||||
|
||||
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));
|
||||
|
||||
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]);
|
||||
@@ -41,7 +42,7 @@ class TypingController extends Controller
|
||||
|
||||
public function index(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$this->assertParticipant($request, $conversationId);
|
||||
$this->findConversationOrFail($conversationId);
|
||||
$userId = (int) $request->user()->id;
|
||||
|
||||
$participants = ConversationParticipant::query()
|
||||
@@ -93,4 +94,12 @@ class TypingController extends Controller
|
||||
return Cache::store();
|
||||
}
|
||||
}
|
||||
|
||||
private function findConversationOrFail(int $conversationId): Conversation
|
||||
{
|
||||
$conversation = Conversation::query()->findOrFail($conversationId);
|
||||
$this->authorize('view', $conversation);
|
||||
|
||||
return $conversation;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,14 @@ final class ProfileApiController extends Controller
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
$sort = $request->input('sort', 'latest');
|
||||
|
||||
$query = Artwork::with('user:id,name,username')
|
||||
$query = Artwork::with([
|
||||
'user:id,name,username,level,rank',
|
||||
'stats:artwork_id,views,downloads,favorites',
|
||||
'categories' => function ($query) {
|
||||
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['contentType:id,slug,name']);
|
||||
},
|
||||
])
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at');
|
||||
|
||||
@@ -106,7 +113,14 @@ final class ProfileApiController extends Controller
|
||||
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
||||
}
|
||||
|
||||
$indexed = Artwork::with('user:id,name,username')
|
||||
$indexed = Artwork::with([
|
||||
'user:id,name,username,level,rank',
|
||||
'stats:artwork_id,views,downloads,favorites',
|
||||
'categories' => function ($query) {
|
||||
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['contentType:id,slug,name']);
|
||||
},
|
||||
])
|
||||
->whereIn('id', $favIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
@@ -173,6 +187,9 @@ final class ProfileApiController extends Controller
|
||||
private function mapArtworkCardPayload(Artwork $art): array
|
||||
{
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
$category = $art->categories->first();
|
||||
$contentType = $category?->contentType;
|
||||
$stats = $art->stats;
|
||||
|
||||
return [
|
||||
'id' => $art->id,
|
||||
@@ -183,6 +200,13 @@ final class ProfileApiController extends Controller
|
||||
'height' => $art->height,
|
||||
'username' => $art->user->username ?? null,
|
||||
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
|
||||
'content_type' => $contentType?->name,
|
||||
'content_type_slug' => $contentType?->slug,
|
||||
'category' => $category?->name,
|
||||
'category_slug' => $category?->slug,
|
||||
'views' => (int) ($stats?->views ?? $art->view_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0),
|
||||
'published_at' => $this->formatIsoDate($art->published_at),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -11,37 +11,93 @@ class FollowerController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$user = $request->user();
|
||||
$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)
|
||||
$followers = DB::table('user_followers as uf')
|
||||
$baseQuery = DB::table('user_followers as uf')
|
||||
->join('users as u', 'u.id', '=', 'uf.follower_id')
|
||||
->leftJoin('user_profiles as up', 'up.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)
|
||||
->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([
|
||||
'u.id', 'u.username', 'u.name',
|
||||
'up.avatar_hash',
|
||||
'us.uploads_count',
|
||||
'us.followers_count',
|
||||
'uf.created_at as followed_at',
|
||||
'mutual.created_at as followed_back_at',
|
||||
])
|
||||
->paginate($perPage)
|
||||
->withQueryString()
|
||||
->through(fn ($row) => (object) [
|
||||
'id' => $row->id,
|
||||
'username' => $row->username,
|
||||
'uname' => $row->username ?? $row->name,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
|
||||
'id' => $row->id,
|
||||
'name' => $row->name,
|
||||
'username' => $row->username,
|
||||
'uname' => $row->username ?? $row->name,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
|
||||
'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,
|
||||
]);
|
||||
|
||||
$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', [
|
||||
'followers' => $followers,
|
||||
'followers' => $followers,
|
||||
'filters' => [
|
||||
'q' => $search,
|
||||
'sort' => $sort,
|
||||
'relationship' => $relationship,
|
||||
],
|
||||
'summary' => $summary,
|
||||
'page_title' => 'My Followers',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -11,40 +11,93 @@ class FollowingController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$user = $request->user();
|
||||
$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)
|
||||
$following = DB::table('user_followers as uf')
|
||||
$baseQuery = DB::table('user_followers as uf')
|
||||
->join('users as u', 'u.id', '=', 'uf.user_id')
|
||||
->leftJoin('user_profiles as up', 'up.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)
|
||||
->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([
|
||||
'u.id', 'u.username', 'u.name',
|
||||
'up.avatar_hash',
|
||||
'us.uploads_count',
|
||||
'us.followers_count',
|
||||
'uf.created_at as followed_at',
|
||||
'mutual.created_at as follows_you_at',
|
||||
])
|
||||
->paginate($perPage)
|
||||
->withQueryString()
|
||||
->through(fn ($row) => (object) [
|
||||
'id' => $row->id,
|
||||
'username' => $row->username,
|
||||
'name' => $row->name,
|
||||
'uname' => $row->username ?? $row->name,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
|
||||
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
|
||||
'uploads' => $row->uploads_count ?? 0,
|
||||
'followers_count'=> $row->followers_count ?? 0,
|
||||
'followed_at' => $row->followed_at,
|
||||
'id' => $row->id,
|
||||
'username' => $row->username,
|
||||
'name' => $row->name,
|
||||
'uname' => $row->username ?? $row->name,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
|
||||
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
|
||||
'uploads' => $row->uploads_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,
|
||||
]);
|
||||
|
||||
$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', [
|
||||
'following' => $following,
|
||||
'following' => $following,
|
||||
'filters' => [
|
||||
'q' => $search,
|
||||
'sort' => $sort,
|
||||
'relationship' => $relationship,
|
||||
],
|
||||
'summary' => $summary,
|
||||
'page_title' => 'People I Follow',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,18 @@ use Inertia\Inertia;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
private const PROFILE_TABS = [
|
||||
'posts',
|
||||
'artworks',
|
||||
'stories',
|
||||
'achievements',
|
||||
'collections',
|
||||
'about',
|
||||
'stats',
|
||||
'favourites',
|
||||
'activity',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ArtworkService $artworkService,
|
||||
private readonly UsernameApprovalService $usernameApprovalService,
|
||||
@@ -84,7 +96,12 @@ class ProfileController extends Controller
|
||||
return redirect()->route('profile.show', ['username' => strtolower((string) $user->username)], 301);
|
||||
}
|
||||
|
||||
return $this->renderProfilePage($request, $user);
|
||||
$tab = $this->normalizeProfileTab($request->query('tab'));
|
||||
if ($tab !== null) {
|
||||
return $this->redirectToProfileTab($request, (string) $user->username, $tab);
|
||||
}
|
||||
|
||||
return $this->renderProfilePage($request, $user, 'Profile/ProfileShow', false, 'posts');
|
||||
}
|
||||
|
||||
public function showGalleryByUsername(Request $request, string $username)
|
||||
@@ -111,6 +128,45 @@ class ProfileController extends Controller
|
||||
return $this->renderProfilePage($request, $user, 'Profile/ProfileGallery', true);
|
||||
}
|
||||
|
||||
public function showTabByUsername(Request $request, string $username, string $tab)
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
$user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||
$normalizedTab = $this->normalizeProfileTab($tab);
|
||||
|
||||
if ($normalizedTab === null) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user) {
|
||||
$redirect = DB::table('username_redirects')
|
||||
->whereRaw('LOWER(old_username) = ?', [$normalized])
|
||||
->value('new_username');
|
||||
|
||||
if ($redirect) {
|
||||
return redirect()->route('profile.tab', [
|
||||
'username' => strtolower((string) $redirect),
|
||||
'tab' => $normalizedTab,
|
||||
], 301);
|
||||
}
|
||||
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($username !== strtolower((string) $user->username)) {
|
||||
return redirect()->route('profile.tab', [
|
||||
'username' => strtolower((string) $user->username),
|
||||
'tab' => $normalizedTab,
|
||||
], 301);
|
||||
}
|
||||
|
||||
if ($request->query->has('tab')) {
|
||||
return $this->redirectToProfileTab($request, (string) $user->username, $normalizedTab);
|
||||
}
|
||||
|
||||
return $this->renderProfilePage($request, $user, 'Profile/ProfileShow', false, $normalizedTab);
|
||||
}
|
||||
|
||||
public function legacyById(Request $request, int $id, ?string $username = null)
|
||||
{
|
||||
$user = User::query()->findOrFail($id);
|
||||
@@ -836,7 +892,13 @@ class ProfileController extends Controller
|
||||
return Redirect::route('dashboard.profile')->with('status', 'password-updated');
|
||||
}
|
||||
|
||||
private function renderProfilePage(Request $request, User $user, string $component = 'Profile/ProfileShow', bool $galleryOnly = false)
|
||||
private function renderProfilePage(
|
||||
Request $request,
|
||||
User $user,
|
||||
string $component = 'Profile/ProfileShow',
|
||||
bool $galleryOnly = false,
|
||||
?string $initialTab = null,
|
||||
)
|
||||
{
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
$viewer = Auth::user();
|
||||
@@ -1088,8 +1150,19 @@ class ProfileController extends Controller
|
||||
$usernameSlug = strtolower((string) ($user->username ?? ''));
|
||||
$canonical = url('/@' . $usernameSlug);
|
||||
$galleryUrl = url('/@' . $usernameSlug . '/gallery');
|
||||
$profileTabUrls = collect(self::PROFILE_TABS)
|
||||
->mapWithKeys(fn (string $tab) => [$tab => url('/@' . $usernameSlug . '/' . $tab)])
|
||||
->all();
|
||||
$achievementSummary = $this->achievements->summary((int) $user->id);
|
||||
$leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id);
|
||||
$resolvedInitialTab = $this->normalizeProfileTab($initialTab);
|
||||
$isTabLanding = ! $galleryOnly && $resolvedInitialTab !== null;
|
||||
$activeProfileUrl = $resolvedInitialTab !== null
|
||||
? ($profileTabUrls[$resolvedInitialTab] ?? $canonical)
|
||||
: $canonical;
|
||||
$tabMetaLabel = $resolvedInitialTab !== null
|
||||
? ucfirst($resolvedInitialTab)
|
||||
: null;
|
||||
|
||||
return Inertia::render($component, [
|
||||
'user' => [
|
||||
@@ -1133,20 +1206,51 @@ class ProfileController extends Controller
|
||||
'countryName' => $countryName,
|
||||
'isOwner' => $isOwner,
|
||||
'auth' => $authData,
|
||||
'initialTab' => $resolvedInitialTab,
|
||||
'profileUrl' => $canonical,
|
||||
'galleryUrl' => $galleryUrl,
|
||||
'profileTabUrls' => $profileTabUrls,
|
||||
])->withViewData([
|
||||
'page_title' => $galleryOnly
|
||||
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
|
||||
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase'),
|
||||
'page_canonical' => $galleryOnly ? $galleryUrl : $canonical,
|
||||
: ($isTabLanding
|
||||
? (($user->username ?? $user->name ?? 'User') . ' ' . $tabMetaLabel . ' on Skinbase')
|
||||
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase')),
|
||||
'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl,
|
||||
'page_meta_description' => $galleryOnly
|
||||
? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.')
|
||||
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.'),
|
||||
: ($isTabLanding
|
||||
? ('Explore the ' . strtolower((string) $tabMetaLabel) . ' section for ' . ($user->username ?? $user->name) . ' on Skinbase.')
|
||||
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.')),
|
||||
'og_image' => $avatarUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
private function normalizeProfileTab(mixed $tab): ?string
|
||||
{
|
||||
if (! is_string($tab)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim($tab));
|
||||
|
||||
return in_array($normalized, self::PROFILE_TABS, true) ? $normalized : null;
|
||||
}
|
||||
|
||||
private function redirectToProfileTab(Request $request, string $username, string $tab): RedirectResponse
|
||||
{
|
||||
$baseUrl = url('/@' . strtolower($username) . '/' . $tab);
|
||||
|
||||
$query = $request->query();
|
||||
unset($query['tab']);
|
||||
|
||||
if ($query !== []) {
|
||||
$baseUrl .= '?' . http_build_query($query);
|
||||
}
|
||||
|
||||
return redirect()->to($baseUrl, 301);
|
||||
}
|
||||
|
||||
private function resolveFavouriteTable(): ?string
|
||||
{
|
||||
foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) {
|
||||
@@ -1164,6 +1268,9 @@ class ProfileController extends Controller
|
||||
private function mapArtworkCardPayload(Artwork $art): array
|
||||
{
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
$category = $art->categories->first();
|
||||
$contentType = $category?->contentType;
|
||||
$stats = $art->stats;
|
||||
|
||||
return [
|
||||
'id' => $art->id,
|
||||
@@ -1178,6 +1285,13 @@ class ProfileController extends Controller
|
||||
'user_id' => $art->user_id,
|
||||
'author_level' => (int) ($art->user?->level ?? 1),
|
||||
'author_rank' => (string) ($art->user?->rank ?? 'Newbie'),
|
||||
'content_type' => $contentType?->name,
|
||||
'content_type_slug' => $contentType?->slug,
|
||||
'category' => $category?->name,
|
||||
'category_slug' => $category?->slug,
|
||||
'views' => (int) ($stats?->views ?? $art->view_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0),
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
];
|
||||
|
||||
@@ -8,8 +8,11 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\ErrorSuggestionService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
@@ -167,23 +170,38 @@ final class ArtworkPageController extends Controller
|
||||
|
||||
// Recursive helper to format a comment and its nested replies
|
||||
$formatComment = null;
|
||||
$formatComment = function(ArtworkComment $c) use (&$formatComment) {
|
||||
$formatComment = function (ArtworkComment $c) use (&$formatComment): array {
|
||||
$replies = $c->relationLoaded('approvedReplies') ? $c->approvedReplies : collect();
|
||||
$user = $c->user;
|
||||
$userId = (int) ($c->user_id ?? 0);
|
||||
$avatarHash = $user?->profile?->avatar_hash ?? null;
|
||||
$canPublishLinks = (int) ($user?->level ?? 1) > 1 && strtolower((string) ($user?->rank ?? 'Newbie')) !== 'newbie';
|
||||
$rawContent = (string) ($c->raw_content ?? $c->content ?? '');
|
||||
$renderedContent = $c->rendered_content;
|
||||
|
||||
if (! is_string($renderedContent) || trim($renderedContent) === '') {
|
||||
$renderedContent = $rawContent !== ''
|
||||
? ContentSanitizer::render($rawContent)
|
||||
: nl2br(e(strip_tags((string) ($c->content ?? ''))));
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'parent_id' => $c->parent_id,
|
||||
'content' => html_entity_decode((string) $c->content, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'raw_content' => $c->raw_content ?? $c->content,
|
||||
'rendered_content' => $c->rendered_content,
|
||||
'created_at' => $c->created_at?->toIsoString(),
|
||||
'rendered_content' => ContentSanitizer::sanitizeRenderedHtml($renderedContent, $canPublishLinks),
|
||||
'created_at' => $c->created_at?->toIso8601String(),
|
||||
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
||||
'user' => [
|
||||
'id' => $c->user?->id,
|
||||
'name' => $c->user?->name,
|
||||
'username' => $c->user?->username,
|
||||
'display' => $c->user?->username ?? $c->user?->name ?? 'User',
|
||||
'profile_url' => $c->user?->username ? '/@' . $c->user->username : null,
|
||||
'avatar_url' => $c->user?->profile?->avatar_url,
|
||||
'id' => $userId,
|
||||
'name' => $user?->name,
|
||||
'username' => $user?->username,
|
||||
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||
'profile_url' => $user?->username ? '/@' . $user->username : ($userId > 0 ? '/profile/' . $userId : null),
|
||||
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
||||
'level' => (int) ($user?->level ?? 1),
|
||||
'rank' => (string) ($user?->rank ?? 'Newbie'),
|
||||
],
|
||||
'replies' => $replies->map($formatComment)->values()->all(),
|
||||
];
|
||||
|
||||
@@ -1,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]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
20
app/Http/Requests/Messaging/RenameConversationRequest.php
Normal file
20
app/Http/Requests/Messaging/RenameConversationRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Http/Requests/Messaging/StoreConversationRequest.php
Normal file
26
app/Http/Requests/Messaging/StoreConversationRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Http/Requests/Messaging/StoreMessageRequest.php
Normal file
24
app/Http/Requests/Messaging/StoreMessageRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
20
app/Http/Requests/Messaging/ToggleMessageReactionRequest.php
Normal file
20
app/Http/Requests/Messaging/ToggleMessageReactionRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
20
app/Http/Requests/Messaging/UpdateMessageRequest.php
Normal file
20
app/Http/Requests/Messaging/UpdateMessageRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -100,6 +101,7 @@ class ArtworkResource extends JsonResource
|
||||
'slug' => (string) $this->slug,
|
||||
'title' => $decode($this->title),
|
||||
'description' => $decode($this->description),
|
||||
'description_html' => $this->renderDescriptionHtml(),
|
||||
'dimensions' => [
|
||||
'width' => (int) ($this->width ?? 0),
|
||||
'height' => (int) ($this->height ?? 0),
|
||||
@@ -123,6 +125,8 @@ class ArtworkResource extends JsonResource
|
||||
'username' => (string) ($this->user?->username ?? ''),
|
||||
'profile_url' => $this->user?->username ? '/@' . $this->user->username : null,
|
||||
'avatar_url' => $this->user?->profile?->avatar_url,
|
||||
'level' => (int) ($this->user?->level ?? 1),
|
||||
'rank' => (string) ($this->user?->rank ?? 'Newbie'),
|
||||
'followers_count' => $followerCount,
|
||||
],
|
||||
'viewer' => [
|
||||
@@ -168,4 +172,27 @@ class ArtworkResource extends JsonResource
|
||||
])->values(),
|
||||
];
|
||||
}
|
||||
|
||||
private function renderDescriptionHtml(): string
|
||||
{
|
||||
$rawDescription = (string) ($this->description ?? '');
|
||||
|
||||
if (trim($rawDescription) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (! $this->authorCanPublishLinks()) {
|
||||
return nl2br(e(ContentSanitizer::stripToPlain($rawDescription)));
|
||||
}
|
||||
|
||||
return ContentSanitizer::render($rawDescription);
|
||||
}
|
||||
|
||||
private function authorCanPublishLinks(): bool
|
||||
{
|
||||
$level = (int) ($this->user?->level ?? 1);
|
||||
$rank = strtolower((string) ($this->user?->rank ?? 'Newbie'));
|
||||
|
||||
return $level > 1 && $rank !== 'newbie';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Jobs;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkEmbedding;
|
||||
use App\Services\Vision\ArtworkEmbeddingClient;
|
||||
use App\Services\Vision\ArtworkVisionImageUrl;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -41,7 +42,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
return [2, 10, 30];
|
||||
}
|
||||
|
||||
public function handle(ArtworkEmbeddingClient $client): void
|
||||
public function handle(ArtworkEmbeddingClient $client, ArtworkVisionImageUrl $imageUrlBuilder): void
|
||||
{
|
||||
if (! (bool) config('recommendations.embedding.enabled', true)) {
|
||||
return;
|
||||
@@ -79,7 +80,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
}
|
||||
|
||||
try {
|
||||
$imageUrl = $this->buildImageUrl($sourceHash);
|
||||
$imageUrl = $imageUrlBuilder->fromHash($sourceHash, (string) ($artwork->thumb_ext ?: 'webp'));
|
||||
if ($imageUrl === null) {
|
||||
return;
|
||||
}
|
||||
@@ -134,21 +135,6 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
return array_map(static fn (float $value): float => $value / $norm, $vector);
|
||||
}
|
||||
|
||||
private function buildImageUrl(string $hash): ?string
|
||||
{
|
||||
$base = rtrim((string) config('cdn.files_url', ''), '/');
|
||||
if ($base === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$variant = (string) config('vision.image_variant', 'md');
|
||||
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));
|
||||
$clean = str_pad($clean, 6, '0');
|
||||
$segments = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00'];
|
||||
|
||||
return $base . '/img/' . implode('/', $segments) . '/' . $variant . '.webp';
|
||||
}
|
||||
|
||||
private function lockKey(int $artworkId, string $model, string $version): string
|
||||
{
|
||||
return 'artwork-embedding:lock:' . $artworkId . ':' . $model . ':' . $version;
|
||||
|
||||
@@ -23,14 +23,18 @@ class Conversation extends Model
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'type',
|
||||
'title',
|
||||
'created_by',
|
||||
'last_message_id',
|
||||
'last_message_at',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_message_at' => 'datetime',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
// ── Relationships ────────────────────────────────────────────────────────
|
||||
@@ -81,6 +85,7 @@ class Conversation extends Model
|
||||
{
|
||||
return self::query()
|
||||
->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', $userB)->whereNull('left_at'))
|
||||
->whereRaw(
|
||||
@@ -108,6 +113,11 @@ class Conversation extends Model
|
||||
->whereNull('deleted_at')
|
||||
->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) {
|
||||
$query->where('created_at', '>', $participant->last_read_at);
|
||||
}
|
||||
|
||||
@@ -30,9 +30,11 @@ class ConversationParticipant extends Model
|
||||
'user_id',
|
||||
'role',
|
||||
'last_read_at',
|
||||
'last_read_message_id',
|
||||
'is_muted',
|
||||
'is_archived',
|
||||
'is_pinned',
|
||||
'is_hidden',
|
||||
'pinned_at',
|
||||
'joined_at',
|
||||
'left_at',
|
||||
@@ -40,9 +42,11 @@ class ConversationParticipant extends Model
|
||||
|
||||
protected $casts = [
|
||||
'last_read_at' => 'datetime',
|
||||
'last_read_message_id' => 'integer',
|
||||
'is_muted' => 'boolean',
|
||||
'is_archived' => 'boolean',
|
||||
'is_pinned' => 'boolean',
|
||||
'is_hidden' => 'boolean',
|
||||
'pinned_at' => 'datetime',
|
||||
'joined_at' => 'datetime',
|
||||
'left_at' => 'datetime',
|
||||
|
||||
@@ -7,8 +7,11 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
use App\Models\MessageRead;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $conversation_id
|
||||
@@ -24,16 +27,31 @@ class Message extends Model
|
||||
use HasFactory, SoftDeletes, Searchable;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'client_temp_id',
|
||||
'conversation_id',
|
||||
'sender_id',
|
||||
'message_type',
|
||||
'body',
|
||||
'meta_json',
|
||||
'reply_to_message_id',
|
||||
'edited_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'meta_json' => 'array',
|
||||
'edited_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $message): void {
|
||||
if (! $message->uuid) {
|
||||
$message->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Relationships ────────────────────────────────────────────────────────
|
||||
|
||||
public function conversation(): BelongsTo
|
||||
@@ -56,9 +74,14 @@ class Message extends Model
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class MessageAttachment extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'message_id',
|
||||
'disk',
|
||||
'user_id',
|
||||
'type',
|
||||
'mime',
|
||||
|
||||
34
app/Models/MessageRead.php
Normal file
34
app/Models/MessageRead.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ class ArtworkSharedNotification extends Notification implements ShouldQueue
|
||||
'sharer_name' => $this->sharer->name,
|
||||
'sharer_username' => $this->sharer->username,
|
||||
'message' => $this->sharer->name . ' shared your artwork "' . $this->artwork->title . '"',
|
||||
'url' => "/@{$this->sharer->username}?tab=posts",
|
||||
'url' => "/@{$this->sharer->username}/posts",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class PostCommentedNotification extends Notification implements ShouldQueue
|
||||
'commenter_name' => $this->commenter->name,
|
||||
'commenter_username' => $this->commenter->username,
|
||||
'message' => "{$this->commenter->name} commented on your post",
|
||||
'url' => "/@{$this->post->user->username}?tab=posts",
|
||||
'url' => "/@{$this->post->user->username}/posts",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
47
app/Policies/ConversationPolicy.php
Normal file
47
app/Policies/ConversationPolicy.php
Normal 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();
|
||||
}
|
||||
}
|
||||
29
app/Policies/MessagePolicy.php
Normal file
29
app/Policies/MessagePolicy.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -290,6 +290,44 @@ class AppServiceProvider extends ServiceProvider
|
||||
Limit::perMinute(120)->by('messages:react:ip:' . $request->ip()),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('messages-read', function (Request $request): array {
|
||||
$userId = $request->user()?->id ?? 'guest';
|
||||
|
||||
return [
|
||||
Limit::perMinute(120)->by('messages:read:user:' . $userId),
|
||||
Limit::perMinute(240)->by('messages:read:ip:' . $request->ip()),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('messages-typing', function (Request $request): array {
|
||||
$userId = $request->user()?->id ?? 'guest';
|
||||
$conversationId = (int) $request->route('conversation_id');
|
||||
|
||||
return [
|
||||
Limit::perMinute(90)->by('messages:typing:user:' . $userId . ':conv:' . $conversationId),
|
||||
Limit::perMinute(180)->by('messages:typing:ip:' . $request->ip()),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('messages-recovery', function (Request $request): array {
|
||||
$userId = $request->user()?->id ?? 'guest';
|
||||
$conversationId = (int) $request->route('conversation_id');
|
||||
|
||||
return [
|
||||
Limit::perMinute(30)->by('messages:recovery:user:' . $userId . ':conv:' . $conversationId),
|
||||
Limit::perMinute(60)->by('messages:recovery:ip:' . $request->ip()),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('messages-presence', function (Request $request): array {
|
||||
$userId = $request->user()?->id ?? 'guest';
|
||||
|
||||
return [
|
||||
Limit::perMinute(180)->by('messages:presence:user:' . $userId),
|
||||
Limit::perMinute(300)->by('messages:presence:ip:' . $request->ip()),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private function configureDownloadRateLimiter(): void
|
||||
@@ -396,8 +434,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
try {
|
||||
/** @var Menu $menu */
|
||||
$menu = $this->app->make(Menu::class);
|
||||
$menu->addHeaderItem('Countries', 'fa-solid fa-flag', 'admin.cp.countries.main');
|
||||
$menu->addItem('Users', 'Countries', 'fa-solid fa-flag', 'admin.cp.countries.main');
|
||||
} catch (\Throwable) {
|
||||
// Control panel menu registration should never block the app boot.
|
||||
}
|
||||
|
||||
34
app/Providers/HorizonServiceProvider.php
Normal file
34
app/Providers/HorizonServiceProvider.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Horizon\HorizonApplicationServiceProvider;
|
||||
|
||||
class HorizonServiceProvider extends HorizonApplicationServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
// Horizon::routeSmsNotificationsTo('15556667777');
|
||||
// Horizon::routeMailNotificationsTo('example@example.com');
|
||||
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Horizon gate.
|
||||
*
|
||||
* This gate determines who can access Horizon in non-local environments.
|
||||
*/
|
||||
protected function gate(): void
|
||||
{
|
||||
Gate::define('viewHorizon', function ($user = null) {
|
||||
return app()->environment('local')
|
||||
|| (is_object($user) && method_exists($user, 'isAdmin') && $user->isAdmin());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -301,7 +301,8 @@ class ArtworkService
|
||||
{
|
||||
$query = Artwork::where('user_id', $userId)
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user:id,name,username,level,rank',
|
||||
'stats:artwork_id,views,downloads,favorites',
|
||||
'categories' => function ($q) {
|
||||
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
||||
|
||||
@@ -72,6 +72,20 @@ class ContentSanitizer
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize previously rendered HTML for display-time policy changes.
|
||||
* This is useful when stored HTML predates current link attributes or
|
||||
* when display rules depend on the author rather than the raw content.
|
||||
*/
|
||||
public static function sanitizeRenderedHtml(?string $html, bool $allowLinks = true): string
|
||||
{
|
||||
if ($html === null || trim($html) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return static::sanitizeHtml($html, $allowLinks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ALL HTML from input, returning plain text with newlines preserved.
|
||||
*/
|
||||
@@ -190,7 +204,7 @@ class ContentSanitizer
|
||||
* Whitelist-based HTML sanitizer.
|
||||
* Removes all tags not in ALLOWED_TAGS, and strips disallowed attributes.
|
||||
*/
|
||||
private static function sanitizeHtml(string $html): string
|
||||
private static function sanitizeHtml(string $html, bool $allowLinks = true): string
|
||||
{
|
||||
// Parse with DOMDocument
|
||||
$doc = new \DOMDocument('1.0', 'UTF-8');
|
||||
@@ -202,7 +216,7 @@ class ContentSanitizer
|
||||
);
|
||||
libxml_clear_errors();
|
||||
|
||||
static::cleanNode($doc->getElementsByTagName('body')->item(0));
|
||||
static::cleanNode($doc->getElementsByTagName('body')->item(0), $allowLinks);
|
||||
|
||||
// Serialize back, removing the wrapping html/body
|
||||
$body = $doc->getElementsByTagName('body')->item(0);
|
||||
@@ -218,13 +232,17 @@ class ContentSanitizer
|
||||
/**
|
||||
* Recursively clean a DOMNode — strip forbidden tags/attributes.
|
||||
*/
|
||||
private static function cleanNode(\DOMNode $node): void
|
||||
private static function cleanNode(\DOMNode $node, bool $allowLinks = true): void
|
||||
{
|
||||
$toRemove = [];
|
||||
$toUnwrap = [];
|
||||
|
||||
foreach ($node->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
if (! $child instanceof \DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tag = strtolower($child->nodeName);
|
||||
|
||||
if (! in_array($tag, self::ALLOWED_TAGS, true)) {
|
||||
@@ -245,17 +263,22 @@ class ContentSanitizer
|
||||
|
||||
// Force external links to be safe
|
||||
if ($tag === 'a') {
|
||||
if (! $allowLinks) {
|
||||
$toUnwrap[] = $child;
|
||||
continue;
|
||||
}
|
||||
|
||||
$href = $child->getAttribute('href');
|
||||
if ($href && ! static::isSafeUrl($href)) {
|
||||
$toUnwrap[] = $child;
|
||||
continue;
|
||||
}
|
||||
$child->setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
$child->setAttribute('rel', 'noopener noreferrer nofollow ugc');
|
||||
$child->setAttribute('target', '_blank');
|
||||
}
|
||||
|
||||
// Recurse
|
||||
static::cleanNode($child);
|
||||
static::cleanNode($child, $allowLinks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
app/Services/Messaging/ConversationDeltaService.php
Normal file
31
app/Services/Messaging/ConversationDeltaService.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Messaging;
|
||||
|
||||
use App\Models\Conversation;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ConversationDeltaService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessagingPayloadFactory $payloadFactory,
|
||||
) {}
|
||||
|
||||
public function messagesAfter(Conversation $conversation, User $viewer, int $afterMessageId, ?int $limit = null): Collection
|
||||
{
|
||||
$maxMessages = max(1, (int) config('messaging.recovery.max_messages', 100));
|
||||
$effectiveLimit = min($limit ?? $maxMessages, $maxMessages);
|
||||
|
||||
return Message::withTrashed()
|
||||
->where('conversation_id', $conversation->id)
|
||||
->where('id', '>', $afterMessageId)
|
||||
->with(['sender:id,username,name', 'reactions', 'attachments'])
|
||||
->orderBy('id')
|
||||
->limit($effectiveLimit)
|
||||
->get()
|
||||
->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $viewer->id))
|
||||
->values();
|
||||
}
|
||||
}
|
||||
76
app/Services/Messaging/ConversationReadService.php
Normal file
76
app/Services/Messaging/ConversationReadService.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Messaging;
|
||||
|
||||
use App\Events\ConversationUpdated;
|
||||
use App\Events\MessageRead;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ConversationReadService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ConversationStateService $conversationState,
|
||||
) {}
|
||||
|
||||
public function markConversationRead(Conversation $conversation, User $user, ?int $messageId = null): ConversationParticipant
|
||||
{
|
||||
/** @var ConversationParticipant $participant */
|
||||
$participant = ConversationParticipant::query()
|
||||
->where('conversation_id', $conversation->id)
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('left_at')
|
||||
->firstOrFail();
|
||||
|
||||
$lastReadableMessage = Message::query()
|
||||
->where('conversation_id', $conversation->id)
|
||||
->whereNull('deleted_at')
|
||||
->where('sender_id', '!=', $user->id)
|
||||
->when($messageId, fn ($query) => $query->where('id', '<=', $messageId))
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
$readAt = now();
|
||||
|
||||
$participant->forceFill([
|
||||
'last_read_at' => $readAt,
|
||||
'last_read_message_id' => $lastReadableMessage?->id,
|
||||
])->save();
|
||||
|
||||
if ($lastReadableMessage) {
|
||||
$messageReads = Message::query()
|
||||
->select(['id'])
|
||||
->where('conversation_id', $conversation->id)
|
||||
->whereNull('deleted_at')
|
||||
->where('sender_id', '!=', $user->id)
|
||||
->where('id', '<=', $lastReadableMessage->id)
|
||||
->get()
|
||||
->map(fn (Message $message) => [
|
||||
'message_id' => $message->id,
|
||||
'user_id' => $user->id,
|
||||
'read_at' => $readAt,
|
||||
])
|
||||
->all();
|
||||
|
||||
if (! empty($messageReads)) {
|
||||
DB::table('message_reads')->upsert($messageReads, ['message_id', 'user_id'], ['read_at']);
|
||||
}
|
||||
}
|
||||
|
||||
$participantIds = $this->conversationState->activeParticipantIds($conversation);
|
||||
$this->conversationState->touchConversationCachesForUsers($participantIds);
|
||||
|
||||
DB::afterCommit(function () use ($conversation, $participant, $user, $participantIds): void {
|
||||
event(new MessageRead($conversation, $participant, $user));
|
||||
|
||||
foreach ($participantIds as $participantId) {
|
||||
event(new ConversationUpdated($participantId, $conversation, 'message.read'));
|
||||
}
|
||||
});
|
||||
|
||||
return $participant->fresh(['user']);
|
||||
}
|
||||
}
|
||||
35
app/Services/Messaging/ConversationStateService.php
Normal file
35
app/Services/Messaging/ConversationStateService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,10 @@ use Illuminate\Support\Str;
|
||||
|
||||
class MessageNotificationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessagingPresenceService $presence,
|
||||
) {}
|
||||
|
||||
public function notifyNewMessage(Conversation $conversation, Message $message, User $sender): void
|
||||
{
|
||||
if (! DB::getSchemaBuilder()->hasTable('notifications')) {
|
||||
@@ -36,6 +40,13 @@ class MessageNotificationService
|
||||
->whereIn('id', $recipientIds)
|
||||
->get()
|
||||
->filter(fn (User $recipient) => $recipient->allowsMessagesFrom($sender))
|
||||
->filter(function (User $recipient): bool {
|
||||
if (! (bool) config('messaging.notifications.offline_fallback_only', true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! $this->presence->isUserOnline((int) $recipient->id);
|
||||
})
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->values()
|
||||
|
||||
152
app/Services/Messaging/MessagingPayloadFactory.php
Normal file
152
app/Services/Messaging/MessagingPayloadFactory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
69
app/Services/Messaging/MessagingPresenceService.php
Normal file
69
app/Services/Messaging/MessagingPresenceService.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Messaging;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Cache\Repository;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class MessagingPresenceService
|
||||
{
|
||||
public function touch(User|int $user, ?int $conversationId = null): void
|
||||
{
|
||||
$userId = $user instanceof User ? (int) $user->id : (int) $user;
|
||||
$store = $this->store();
|
||||
$onlineKey = $this->onlineKey($userId);
|
||||
$existing = $store->get($onlineKey, []);
|
||||
$previousConversationId = (int) ($existing['conversation_id'] ?? 0) ?: null;
|
||||
$onlineTtl = max(30, (int) config('messaging.presence.ttl_seconds', 90));
|
||||
$conversationTtl = max(15, (int) config('messaging.presence.conversation_ttl_seconds', 45));
|
||||
|
||||
if ($previousConversationId && $previousConversationId !== $conversationId) {
|
||||
$store->forget($this->conversationKey($previousConversationId, $userId));
|
||||
}
|
||||
|
||||
$store->put($onlineKey, [
|
||||
'conversation_id' => $conversationId,
|
||||
'seen_at' => now()->toIso8601String(),
|
||||
], now()->addSeconds($onlineTtl));
|
||||
|
||||
if ($conversationId) {
|
||||
$store->put($this->conversationKey($conversationId, $userId), now()->toIso8601String(), now()->addSeconds($conversationTtl));
|
||||
}
|
||||
}
|
||||
|
||||
public function isUserOnline(int $userId): bool
|
||||
{
|
||||
return $this->store()->has($this->onlineKey($userId));
|
||||
}
|
||||
|
||||
public function isViewingConversation(int $conversationId, int $userId): bool
|
||||
{
|
||||
return $this->store()->has($this->conversationKey($conversationId, $userId));
|
||||
}
|
||||
|
||||
private function onlineKey(int $userId): string
|
||||
{
|
||||
return 'messages:presence:user:' . $userId;
|
||||
}
|
||||
|
||||
private function conversationKey(int $conversationId, int $userId): string
|
||||
{
|
||||
return 'messages:presence:conversation:' . $conversationId . ':user:' . $userId;
|
||||
}
|
||||
|
||||
private function store(): Repository
|
||||
{
|
||||
$store = (string) config('messaging.presence.cache_store', 'redis');
|
||||
|
||||
if ($store === 'redis' && ! class_exists('Redis')) {
|
||||
return Cache::store();
|
||||
}
|
||||
|
||||
try {
|
||||
return Cache::store($store);
|
||||
} catch (\Throwable) {
|
||||
return Cache::store();
|
||||
}
|
||||
}
|
||||
}
|
||||
126
app/Services/Messaging/SendMessageAction.php
Normal file
126
app/Services/Messaging/SendMessageAction.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
81
app/Services/Messaging/UnreadCounterService.php
Normal file
81
app/Services/Messaging/UnreadCounterService.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Messaging;
|
||||
|
||||
use App\Models\Conversation;
|
||||
use App\Models\ConversationParticipant;
|
||||
use App\Models\Message;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class UnreadCounterService
|
||||
{
|
||||
public function applyUnreadCountSelect(Builder $query, User|int $user, string $participantAlias = 'cp_me'): Builder
|
||||
{
|
||||
$userId = $user instanceof User ? (int) $user->id : (int) $user;
|
||||
|
||||
return $query->addSelect([
|
||||
'unread_count' => Message::query()
|
||||
->selectRaw('count(*)')
|
||||
->whereColumn('messages.conversation_id', 'conversations.id')
|
||||
->where('messages.sender_id', '!=', $userId)
|
||||
->whereNull('messages.deleted_at')
|
||||
->where(function ($nested) use ($participantAlias) {
|
||||
$nested->where(function ($group) use ($participantAlias) {
|
||||
$group->whereNull($participantAlias . '.last_read_message_id')
|
||||
->whereNull($participantAlias . '.last_read_at');
|
||||
})->orWhereColumn('messages.id', '>', $participantAlias . '.last_read_message_id')
|
||||
->orWhereColumn('messages.created_at', '>', $participantAlias . '.last_read_at');
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public function unreadCountForConversation(Conversation $conversation, User|int $user): int
|
||||
{
|
||||
$userId = $user instanceof User ? (int) $user->id : (int) $user;
|
||||
|
||||
$participant = ConversationParticipant::query()
|
||||
->where('conversation_id', $conversation->id)
|
||||
->where('user_id', $userId)
|
||||
->whereNull('left_at')
|
||||
->first();
|
||||
|
||||
if (! $participant) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->unreadCountForParticipant($participant);
|
||||
}
|
||||
|
||||
public function unreadCountForParticipant(ConversationParticipant $participant): int
|
||||
{
|
||||
$query = Message::query()
|
||||
->where('conversation_id', $participant->conversation_id)
|
||||
->where('sender_id', '!=', $participant->user_id)
|
||||
->whereNull('deleted_at');
|
||||
|
||||
if ($participant->last_read_message_id) {
|
||||
$query->where('id', '>', $participant->last_read_message_id);
|
||||
} elseif ($participant->last_read_at) {
|
||||
$query->where('created_at', '>', $participant->last_read_at);
|
||||
}
|
||||
|
||||
return (int) $query->count();
|
||||
}
|
||||
|
||||
public function totalUnreadForUser(User|int $user): int
|
||||
{
|
||||
$userId = $user instanceof User ? (int) $user->id : (int) $user;
|
||||
|
||||
return (int) Conversation::query()
|
||||
->select('conversations.id')
|
||||
->join('conversation_participants as cp_me', function ($join) use ($userId) {
|
||||
$join->on('cp_me.conversation_id', '=', 'conversations.id')
|
||||
->where('cp_me.user_id', '=', $userId)
|
||||
->whereNull('cp_me.left_at');
|
||||
})
|
||||
->where('conversations.is_active', true)
|
||||
->get()
|
||||
->sum(fn (Conversation $conversation) => $this->unreadCountForConversation($conversation, $userId));
|
||||
}
|
||||
}
|
||||
29
app/Services/Vision/ArtworkVisionImageUrl.php
Normal file
29
app/Services/Vision/ArtworkVisionImageUrl.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Vision;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ThumbnailService;
|
||||
|
||||
final class ArtworkVisionImageUrl
|
||||
{
|
||||
public function fromArtwork(Artwork $artwork): ?string
|
||||
{
|
||||
return $this->fromHash(
|
||||
(string) ($artwork->hash ?? ''),
|
||||
(string) ($artwork->thumb_ext ?: 'webp')
|
||||
);
|
||||
}
|
||||
|
||||
public function fromHash(?string $hash, ?string $ext = 'webp', string $size = 'md'): ?string
|
||||
{
|
||||
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) $hash));
|
||||
if ($clean === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ThumbnailService::fromHash($clean, $ext, $size);
|
||||
}
|
||||
}
|
||||
213
app/Services/Vision/VectorGatewayClient.php
Normal file
213
app/Services/Vision/VectorGatewayClient.php
Normal file
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Vision;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
final class VectorGatewayClient
|
||||
{
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return (bool) config('vision.vector_gateway.enabled', true)
|
||||
&& $this->baseUrl() !== ''
|
||||
&& $this->apiKey() !== '';
|
||||
}
|
||||
|
||||
public function upsertByUrl(string $imageUrl, int|string $id, array $metadata = []): array
|
||||
{
|
||||
$response = $this->postJson(
|
||||
$this->url((string) config('vision.vector_gateway.upsert_endpoint', '/vectors/upsert')),
|
||||
[
|
||||
'url' => $imageUrl,
|
||||
'id' => (string) $id,
|
||||
'metadata' => $metadata,
|
||||
]
|
||||
);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException($this->failureMessage('Vector upsert', $response));
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
|
||||
*/
|
||||
public function searchByUrl(string $imageUrl, int $limit = 5): array
|
||||
{
|
||||
$response = $this->postJson(
|
||||
$this->url((string) config('vision.vector_gateway.search_endpoint', '/vectors/search')),
|
||||
[
|
||||
'url' => $imageUrl,
|
||||
'limit' => max(1, $limit),
|
||||
]
|
||||
);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException($this->failureMessage('Vector search', $response));
|
||||
}
|
||||
|
||||
return $this->extractMatches($response->json());
|
||||
}
|
||||
|
||||
public function deleteByIds(array $ids): array
|
||||
{
|
||||
$response = $this->postJson(
|
||||
$this->url((string) config('vision.vector_gateway.delete_endpoint', '/vectors/delete')),
|
||||
[
|
||||
'ids' => array_values(array_map(static fn (int|string $id): string => (string) $id, $ids)),
|
||||
]
|
||||
);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException($this->failureMessage('Vector delete', $response));
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
private function request(): PendingRequest
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
throw new RuntimeException('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.');
|
||||
}
|
||||
|
||||
return Http::acceptJson()
|
||||
->withHeaders([
|
||||
'X-API-Key' => $this->apiKey(),
|
||||
])
|
||||
->connectTimeout(max(1, (int) config('vision.vector_gateway.connect_timeout_seconds', 5)))
|
||||
->timeout(max(1, (int) config('vision.vector_gateway.timeout_seconds', 20)))
|
||||
->retry(
|
||||
max(0, (int) config('vision.vector_gateway.retries', 1)),
|
||||
max(0, (int) config('vision.vector_gateway.retry_delay_ms', 250)),
|
||||
throw: false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function postJson(string $url, array $payload): Response
|
||||
{
|
||||
$response = $this->request()->post($url, $payload);
|
||||
|
||||
if (! $response instanceof Response) {
|
||||
throw new RuntimeException('Vector gateway request did not return an HTTP response.');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function baseUrl(): string
|
||||
{
|
||||
return rtrim((string) config('vision.vector_gateway.base_url', ''), '/');
|
||||
}
|
||||
|
||||
private function apiKey(): string
|
||||
{
|
||||
return trim((string) config('vision.vector_gateway.api_key', ''));
|
||||
}
|
||||
|
||||
private function url(string $path): string
|
||||
{
|
||||
return $this->baseUrl() . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
private function failureMessage(string $operation, Response $response): string
|
||||
{
|
||||
$body = trim($response->body());
|
||||
|
||||
if ($body === '') {
|
||||
return $operation . ' failed with HTTP ' . $response->status() . '.';
|
||||
}
|
||||
|
||||
return $operation . ' failed with HTTP ' . $response->status() . ': ' . $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $json
|
||||
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>
|
||||
*/
|
||||
private function extractMatches(mixed $json): array
|
||||
{
|
||||
$candidates = [];
|
||||
|
||||
if (is_array($json)) {
|
||||
$candidates = $this->extractCandidateRows($json);
|
||||
}
|
||||
|
||||
$results = [];
|
||||
foreach ($candidates as $candidate) {
|
||||
if (! is_array($candidate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = $candidate['id']
|
||||
?? $candidate['point_id']
|
||||
?? $candidate['payload']['id']
|
||||
?? $candidate['metadata']['id']
|
||||
?? null;
|
||||
|
||||
if (! is_int($id) && ! is_string($id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$score = $candidate['score']
|
||||
?? $candidate['similarity']
|
||||
?? $candidate['distance']
|
||||
?? 0.0;
|
||||
|
||||
$metadata = $candidate['metadata'] ?? $candidate['payload'] ?? [];
|
||||
if (! is_array($metadata)) {
|
||||
$metadata = [];
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'id' => $id,
|
||||
'score' => (float) $score,
|
||||
'metadata' => $metadata,
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $json
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function extractCandidateRows(array $json): array
|
||||
{
|
||||
$keys = ['results', 'matches', 'points', 'data'];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (! isset($json[$key]) || ! is_array($json[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $json[$key];
|
||||
if (array_is_list($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
foreach (['results', 'matches', 'points', 'items'] as $nestedKey) {
|
||||
if (isset($value[$nestedKey]) && is_array($value[$nestedKey]) && array_is_list($value[$nestedKey])) {
|
||||
return $value[$nestedKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_is_list($json) ? $json : [];
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
channels: __DIR__.'/../routes/channels.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\AuthServiceProvider::class,
|
||||
App\Providers\HorizonServiceProvider::class,
|
||||
Klevze\ControlPanel\ServiceProvider::class,
|
||||
cPad\Plugins\Artworks\ServiceProvider::class,
|
||||
cPad\Plugins\News\ServiceProvider::class,
|
||||
cPad\Plugins\Forum\ServiceProvider::class,
|
||||
cPad\Plugins\News\ServiceProvider::class,
|
||||
cPad\Plugins\Site\ServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"intervention/image": "^3.11",
|
||||
"jenssegers/agent": "*",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/horizon": "^5.45",
|
||||
"laravel/reverb": "^1.0",
|
||||
"laravel/scout": "^10.24",
|
||||
"laravel/socialite": "^5.24",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
|
||||
1145
composer.lock
generated
1145
composer.lock
generated
File diff suppressed because it is too large
Load Diff
82
config/broadcasting.php
Normal file
82
config/broadcasting.php
Normal 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
277
config/horizon.php
Normal file
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This name appears in notifications and in the Horizon UI. Unique names
|
||||
| can be useful while running multiple instances of Horizon within an
|
||||
| application, allowing you to identify the Horizon you're viewing.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('HORIZON_NAME'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This is the subdomain where Horizon will be accessible from. If this
|
||||
| setting is null, Horizon will reside under the same domain as the
|
||||
| application. Otherwise, this value will serve as the subdomain.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('HORIZON_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This is the URI path where Horizon will be accessible from. Feel free
|
||||
| to change this path to anything you like. Note that the URI will not
|
||||
| affect the paths of its internal API that aren't exposed to users.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => env('HORIZON_PATH', 'horizon'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Redis Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This is the name of the Redis connection where Horizon will store the
|
||||
| meta information required for it to function. It includes the list
|
||||
| of supervisors, failed jobs, job metrics, and other information.
|
||||
|
|
||||
*/
|
||||
|
||||
'use' => 'default',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Redis Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This prefix will be used when storing all Horizon data in Redis. You
|
||||
| may modify the prefix when you are running multiple installations
|
||||
| of Horizon on the same server so that they don't have problems.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env(
|
||||
'HORIZON_PREFIX',
|
||||
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Route Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These middleware will get attached onto each Horizon route, giving you
|
||||
| the chance to add your own middleware to this list or change any of
|
||||
| the existing middleware. Or, you can simply stick with this list.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Wait Time Thresholds
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to configure when the LongWaitDetected event
|
||||
| will be fired. Every connection / queue combination may have its
|
||||
| own, unique threshold (in seconds) before this event is fired.
|
||||
|
|
||||
*/
|
||||
|
||||
'waits' => [
|
||||
'redis:broadcasts' => 15,
|
||||
'redis:default' => 60,
|
||||
'redis:notifications' => 90,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Job Trimming Times
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you can configure for how long (in minutes) you desire Horizon to
|
||||
| persist the recent and failed jobs. Typically, recent jobs are kept
|
||||
| for one hour while all failed jobs are stored for an entire week.
|
||||
|
|
||||
*/
|
||||
|
||||
'trim' => [
|
||||
'recent' => 60,
|
||||
'pending' => 60,
|
||||
'completed' => 60,
|
||||
'recent_failed' => 10080,
|
||||
'failed' => 10080,
|
||||
'monitored' => 10080,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Silenced Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Silencing a job will instruct Horizon to not place the job in the list
|
||||
| of completed jobs within the Horizon dashboard. This setting may be
|
||||
| used to fully remove any noisy jobs from the completed jobs list.
|
||||
|
|
||||
*/
|
||||
|
||||
'silenced' => [
|
||||
// App\Jobs\ExampleJob::class,
|
||||
],
|
||||
|
||||
'silenced_tags' => [
|
||||
// 'notifications',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Metrics
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you can configure how many snapshots should be kept to display in
|
||||
| the metrics graph. This will get used in combination with Horizon's
|
||||
| `horizon:snapshot` schedule to define how long to retain metrics.
|
||||
|
|
||||
*/
|
||||
|
||||
'metrics' => [
|
||||
'trim_snapshots' => [
|
||||
'job' => 24,
|
||||
'queue' => 24,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fast Termination
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When this option is enabled, Horizon's "terminate" command will not
|
||||
| wait on all of the workers to terminate unless the --wait option
|
||||
| is provided. Fast termination can shorten deployment delay by
|
||||
| allowing a new instance of Horizon to start while the last
|
||||
| instance will continue to terminate each of its workers.
|
||||
|
|
||||
*/
|
||||
|
||||
'fast_termination' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Memory Limit (MB)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value describes the maximum amount of memory the Horizon master
|
||||
| supervisor may consume before it is terminated and restarted. For
|
||||
| configuring these limits on your workers, see the next section.
|
||||
|
|
||||
*/
|
||||
|
||||
'memory_limit' => 64,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Worker Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the queue worker settings used by your application
|
||||
| in all environments. These supervisors and settings handle all your
|
||||
| queued jobs and will be provisioned by Horizon during deployment.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'supervisor-default' => [
|
||||
'connection' => 'redis',
|
||||
'queue' => ['default'],
|
||||
'balance' => 'auto',
|
||||
'autoScalingStrategy' => 'time',
|
||||
'maxProcesses' => 1,
|
||||
'maxTime' => 0,
|
||||
'maxJobs' => 0,
|
||||
'memory' => 128,
|
||||
'tries' => 1,
|
||||
'timeout' => 60,
|
||||
'nice' => 0,
|
||||
],
|
||||
'supervisor-messaging' => [
|
||||
'connection' => 'redis',
|
||||
'queue' => ['broadcasts', 'notifications'],
|
||||
'balance' => 'auto',
|
||||
'autoScalingStrategy' => 'time',
|
||||
'maxProcesses' => 2,
|
||||
'maxTime' => 0,
|
||||
'maxJobs' => 0,
|
||||
'memory' => 128,
|
||||
'tries' => 1,
|
||||
'timeout' => 90,
|
||||
'nice' => 0,
|
||||
],
|
||||
],
|
||||
|
||||
'environments' => [
|
||||
'production' => [
|
||||
'supervisor-default' => [
|
||||
'maxProcesses' => 10,
|
||||
'balanceMaxShift' => 1,
|
||||
'balanceCooldown' => 3,
|
||||
],
|
||||
'supervisor-messaging' => [
|
||||
'maxProcesses' => 6,
|
||||
'balanceMaxShift' => 1,
|
||||
'balanceCooldown' => 3,
|
||||
],
|
||||
],
|
||||
|
||||
'local' => [
|
||||
'supervisor-default' => [
|
||||
'maxProcesses' => 3,
|
||||
],
|
||||
'supervisor-messaging' => [
|
||||
'maxProcesses' => 2,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| File Watcher Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following list of directories and files will be watched when using
|
||||
| the `horizon:listen` command. Whenever any directories or files are
|
||||
| changed, Horizon will automatically restart to apply all changes.
|
||||
|
|
||||
*/
|
||||
|
||||
'watch' => [
|
||||
'app',
|
||||
'bootstrap',
|
||||
'config/**/*.php',
|
||||
'database/**/*.php',
|
||||
'public/**/*.php',
|
||||
'resources/**/*.php',
|
||||
'routes',
|
||||
'composer.lock',
|
||||
'composer.json',
|
||||
'.env',
|
||||
],
|
||||
];
|
||||
@@ -3,11 +3,29 @@
|
||||
return [
|
||||
'realtime' => (bool) env('MESSAGING_REALTIME', false),
|
||||
|
||||
'broadcast' => [
|
||||
'queue' => env('MESSAGING_BROADCAST_QUEUE', 'broadcasts'),
|
||||
],
|
||||
|
||||
'typing' => [
|
||||
'ttl_seconds' => (int) env('MESSAGING_TYPING_TTL', 8),
|
||||
'cache_store' => env('MESSAGING_TYPING_CACHE_STORE', 'redis'),
|
||||
],
|
||||
|
||||
'presence' => [
|
||||
'ttl_seconds' => (int) env('MESSAGING_PRESENCE_TTL', 90),
|
||||
'conversation_ttl_seconds' => (int) env('MESSAGING_CONVERSATION_PRESENCE_TTL', 45),
|
||||
'cache_store' => env('MESSAGING_PRESENCE_CACHE_STORE', env('MESSAGING_TYPING_CACHE_STORE', 'redis')),
|
||||
],
|
||||
|
||||
'recovery' => [
|
||||
'max_messages' => (int) env('MESSAGING_RECOVERY_MAX_MESSAGES', 100),
|
||||
],
|
||||
|
||||
'notifications' => [
|
||||
'offline_fallback_only' => (bool) env('MESSAGING_OFFLINE_FALLBACK_ONLY', true),
|
||||
],
|
||||
|
||||
'search' => [
|
||||
'index' => env('MESSAGING_MEILI_INDEX', 'messages'),
|
||||
'page_size' => (int) env('MESSAGING_SEARCH_PAGE_SIZE', 20),
|
||||
|
||||
96
config/reverb.php
Normal file
96
config/reverb.php
Normal 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'),
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -44,6 +44,21 @@ return [
|
||||
'connect_timeout_seconds'=> (int) env('VISION_GATEWAY_CONNECT_TIMEOUT', 3),
|
||||
],
|
||||
|
||||
'vector_gateway' => [
|
||||
'enabled' => env('VISION_VECTOR_GATEWAY_ENABLED', true),
|
||||
'base_url' => env('VISION_VECTOR_GATEWAY_URL', ''),
|
||||
'api_key' => env('VISION_VECTOR_GATEWAY_API_KEY', ''),
|
||||
'collection' => env('VISION_VECTOR_GATEWAY_COLLECTION', 'images'),
|
||||
'timeout_seconds' => (int) env('VISION_VECTOR_GATEWAY_TIMEOUT', 20),
|
||||
'connect_timeout_seconds' => (int) env('VISION_VECTOR_GATEWAY_CONNECT_TIMEOUT', 5),
|
||||
'retries' => (int) env('VISION_VECTOR_GATEWAY_RETRIES', 1),
|
||||
'retry_delay_ms' => (int) env('VISION_VECTOR_GATEWAY_RETRY_DELAY_MS', 250),
|
||||
'upsert_endpoint' => env('VISION_VECTOR_GATEWAY_UPSERT_ENDPOINT', '/vectors/upsert'),
|
||||
'search_endpoint' => env('VISION_VECTOR_GATEWAY_SEARCH_ENDPOINT', '/vectors/search'),
|
||||
'delete_endpoint' => env('VISION_VECTOR_GATEWAY_DELETE_ENDPOINT', '/vectors/delete'),
|
||||
'collections_endpoint' => env('VISION_VECTOR_GATEWAY_COLLECTIONS_ENDPOINT', '/vectors/collections'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| LM Studio – local multimodal inference (tag generation)
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
44
docs/realtime-messaging.md
Normal file
44
docs/realtime-messaging.md
Normal 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
30
package-lock.json
generated
@@ -21,7 +21,9 @@
|
||||
"emoji-mart": "^5.6.0",
|
||||
"framer-motion": "^12.34.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"laravel-echo": "^2.3.1",
|
||||
"lowlight": "^3.3.0",
|
||||
"pusher-js": "^8.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.1.0.tgz",
|
||||
@@ -5912,6 +5927,15 @@
|
||||
"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": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
@@ -6807,6 +6831,12 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
|
||||
@@ -48,7 +48,9 @@
|
||||
"emoji-mart": "^5.6.0",
|
||||
"framer-motion": "^12.34.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"laravel-echo": "^2.3.1",
|
||||
"lowlight": "^3.3.0",
|
||||
"pusher-js": "^8.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -85,6 +85,92 @@
|
||||
.nova-scrollbar::-webkit-scrollbar-corner {
|
||||
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 ─── */
|
||||
|
||||
@@ -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 { getEcho } from '../../bootstrap'
|
||||
import ConversationList from '../../components/messaging/ConversationList'
|
||||
import ConversationThread from '../../components/messaging/ConversationThread'
|
||||
import NewConversationModal from '../../components/messaging/NewConversationModal'
|
||||
@@ -10,12 +11,17 @@ function getCsrf() {
|
||||
|
||||
async function apiFetch(url, options = {}) {
|
||||
const isFormData = options.body instanceof FormData
|
||||
const socketId = getEcho()?.socketId?.()
|
||||
const headers = {
|
||||
'X-CSRF-TOKEN': getCsrf(),
|
||||
Accept: 'application/json',
|
||||
...options.headers,
|
||||
}
|
||||
|
||||
if (socketId) {
|
||||
headers['X-Socket-ID'] = socketId
|
||||
}
|
||||
|
||||
if (!isFormData) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
@@ -55,19 +61,23 @@ function buildSearchPreview(item) {
|
||||
|
||||
function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
const [conversations, setConversations] = useState([])
|
||||
const [unreadTotal, setUnreadTotal] = useState(null)
|
||||
const [loadingConvs, setLoadingConvs] = useState(true)
|
||||
const [activeId, setActiveId] = useState(initialId ?? null)
|
||||
const [realtimeEnabled, setRealtimeEnabled] = useState(false)
|
||||
const [realtimeStatus, setRealtimeStatus] = useState('offline')
|
||||
const [onlineUserIds, setOnlineUserIds] = useState([])
|
||||
const [typingByConversation, setTypingByConversation] = useState({})
|
||||
const [showNewModal, setShowNewModal] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const pollRef = useRef(null)
|
||||
|
||||
const loadConversations = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiFetch('/api/messages/conversations')
|
||||
setConversations(data.data ?? [])
|
||||
setUnreadTotal(Number.isFinite(Number(data?.summary?.unread_total)) ? Number(data.summary.unread_total) : null)
|
||||
} catch (e) {
|
||||
console.error('Failed to load conversations', e)
|
||||
} finally {
|
||||
@@ -81,28 +91,293 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
apiFetch('/api/messages/settings')
|
||||
.then((data) => setRealtimeEnabled(!!data?.realtime_enabled))
|
||||
.catch(() => setRealtimeEnabled(false))
|
||||
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
}
|
||||
}, [loadConversations])
|
||||
|
||||
useEffect(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
const handlePopState = () => {
|
||||
const match = window.location.pathname.match(/^\/messages\/(\d+)$/)
|
||||
setActiveId(match ? Number(match[1]) : null)
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', handlePopState)
|
||||
|
||||
return () => window.removeEventListener('popstate', handlePopState)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (realtimeEnabled) {
|
||||
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 () => {
|
||||
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)
|
||||
}
|
||||
window.removeEventListener('focus', syncConnectionState)
|
||||
document.removeEventListener('visibilitychange', handleVisibilitySync)
|
||||
channel.stopListening('.conversation.updated', handleConversationUpdated)
|
||||
echo.leaveChannel(`private-user.${userId}`)
|
||||
}
|
||||
}, [loadConversations, realtimeEnabled])
|
||||
}, [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) => {
|
||||
setActiveId(id)
|
||||
@@ -116,12 +391,24 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
history.replaceState(null, '', `/messages/${conv.id}`)
|
||||
}, [loadConversations])
|
||||
|
||||
const handleMarkRead = useCallback((conversationId) => {
|
||||
const handleMarkRead = useCallback((conversationId, nextUnreadTotal = null) => {
|
||||
setConversations((prev) => prev.map((conversation) => (
|
||||
conversation.id === conversationId
|
||||
? { ...conversation, unread_count: 0 }
|
||||
: conversation
|
||||
)))
|
||||
|
||||
if (Number.isFinite(Number(nextUnreadTotal))) {
|
||||
setUnreadTotal(Number(nextUnreadTotal))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConversationPatched = useCallback((patch) => {
|
||||
if (!patch?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
setConversations((prev) => mergeConversationSummary(prev, patch))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -167,7 +454,9 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
}, [])
|
||||
|
||||
const activeConversation = conversations.find((conversation) => conversation.id === activeId) ?? null
|
||||
const unreadCount = conversations.reduce((sum, conversation) => sum + Number(conversation.unread_count || 0), 0)
|
||||
const unreadCount = Number.isFinite(Number(unreadTotal))
|
||||
? Number(unreadTotal)
|
||||
: conversations.reduce((sum, conversation) => sum + Number(conversation.unread_count || 0), 0)
|
||||
const pinnedCount = conversations.reduce((sum, conversation) => {
|
||||
const me = conversation.my_participant ?? conversation.all_participants?.find((participant) => participant.user_id === userId)
|
||||
return sum + (me?.is_pinned ? 1 : 0)
|
||||
@@ -182,7 +471,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
|| 'Conversation'
|
||||
|
||||
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">
|
||||
<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">
|
||||
@@ -209,9 +498,9 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
</div>
|
||||
|
||||
<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={`h-1.5 w-1.5 rounded-full ${realtimeEnabled ? 'bg-emerald-300' : 'bg-white/30'}`} />
|
||||
{realtimeEnabled ? 'Realtime active' : 'Polling every 15s'}
|
||||
<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 ${connectionDotClass(realtimeEnabled, realtimeStatus)}`} />
|
||||
{connectionBadgeLabel(realtimeEnabled, realtimeStatus)}
|
||||
</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">
|
||||
<i className="fa-solid fa-comments text-[10px]" />
|
||||
@@ -273,6 +562,8 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
loading={loadingConvs}
|
||||
activeId={activeId}
|
||||
currentUserId={userId}
|
||||
onlineUserIds={onlineUserIds}
|
||||
typingByConversation={typingByConversation}
|
||||
onSelect={handleSelectConversation}
|
||||
/>
|
||||
</aside>
|
||||
@@ -284,15 +575,17 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) {
|
||||
conversationId={activeId}
|
||||
conversation={activeConversation}
|
||||
realtimeEnabled={realtimeEnabled}
|
||||
realtimeStatus={realtimeStatus}
|
||||
currentUserId={userId}
|
||||
currentUsername={username}
|
||||
onlineUserIds={onlineUserIds}
|
||||
apiFetch={apiFetch}
|
||||
onBack={() => {
|
||||
setActiveId(null)
|
||||
history.replaceState(null, '', '/messages')
|
||||
}}
|
||||
onMarkRead={handleMarkRead}
|
||||
onConversationUpdated={loadConversations}
|
||||
onConversationPatched={handleConversationPatched}
|
||||
/>
|
||||
) : (
|
||||
<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')
|
||||
|
||||
if (el) {
|
||||
|
||||
@@ -66,10 +66,9 @@ export default function ProfileGallery() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-6xl px-4 pt-6 md:px-6">
|
||||
<div className="w-full pt-6">
|
||||
<ProfileGalleryPanel
|
||||
artworks={artworks}
|
||||
featuredArtworks={featuredArtworks}
|
||||
username={username}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import ProfileHero from '../../components/profile/ProfileHero'
|
||||
import ProfileStatsRow from '../../components/profile/ProfileStatsRow'
|
||||
import ProfileTabs from '../../components/profile/ProfileTabs'
|
||||
import TabArtworks from '../../components/profile/tabs/TabArtworks'
|
||||
import TabAchievements from '../../components/profile/tabs/TabAchievements'
|
||||
@@ -13,16 +12,26 @@ import TabActivity from '../../components/profile/tabs/TabActivity'
|
||||
import TabPosts from '../../components/profile/tabs/TabPosts'
|
||||
import TabStories from '../../components/profile/tabs/TabStories'
|
||||
|
||||
const VALID_TABS = ['artworks', 'stories', 'achievements', 'posts', 'collections', 'about', 'stats', 'favourites', 'activity']
|
||||
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'collections', 'about', 'stats', 'favourites', 'activity']
|
||||
|
||||
function getInitialTab() {
|
||||
try {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const t = sp.get('tab')
|
||||
return VALID_TABS.includes(t) ? t : 'artworks'
|
||||
} catch {
|
||||
return 'artworks'
|
||||
function getInitialTab(initialTab = 'posts') {
|
||||
if (typeof window === 'undefined') {
|
||||
return VALID_TABS.includes(initialTab) ? initialTab : 'posts'
|
||||
}
|
||||
|
||||
try {
|
||||
const pathname = window.location.pathname.replace(/\/+$/, '')
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
const lastSegment = segments.at(-1)
|
||||
|
||||
if (VALID_TABS.includes(lastSegment)) {
|
||||
return lastSegment
|
||||
}
|
||||
} catch {
|
||||
return VALID_TABS.includes(initialTab) ? initialTab : 'posts'
|
||||
}
|
||||
|
||||
return VALID_TABS.includes(initialTab) ? initialTab : 'posts'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,34 +61,37 @@ export default function ProfileShow() {
|
||||
countryName,
|
||||
isOwner,
|
||||
auth,
|
||||
initialTab,
|
||||
profileUrl,
|
||||
galleryUrl,
|
||||
profileTabUrls,
|
||||
} = props
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab)
|
||||
const [activeTab, setActiveTab] = useState(() => getInitialTab(initialTab))
|
||||
|
||||
const handleTabChange = useCallback((tab) => {
|
||||
if (!VALID_TABS.includes(tab)) return
|
||||
setActiveTab(tab)
|
||||
|
||||
// Update URL query param without full navigation
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
if (tab === 'artworks') {
|
||||
url.searchParams.delete('tab')
|
||||
} else {
|
||||
url.searchParams.set('tab', tab)
|
||||
}
|
||||
window.history.pushState({}, '', url.toString())
|
||||
} catch (_) {}
|
||||
}, [])
|
||||
const currentUrl = new URL(window.location.href)
|
||||
const targetBase = profileTabUrls?.[tab] || `${profileUrl || `${window.location.origin}`}/${tab}`
|
||||
const nextUrl = new URL(targetBase, window.location.origin)
|
||||
const sharedPostId = currentUrl.searchParams.get('post')
|
||||
|
||||
if (sharedPostId) {
|
||||
nextUrl.searchParams.set('post', sharedPostId)
|
||||
}
|
||||
|
||||
window.history.pushState({}, '', nextUrl.toString())
|
||||
} catch (_) {}
|
||||
}, [profileTabUrls, profileUrl])
|
||||
|
||||
// Handle browser back/forward
|
||||
useEffect(() => {
|
||||
const onPop = () => setActiveTab(getInitialTab())
|
||||
const onPop = () => setActiveTab(getInitialTab(initialTab))
|
||||
window.addEventListener('popstate', onPop)
|
||||
return () => window.removeEventListener('popstate', onPop)
|
||||
}, [])
|
||||
}, [initialTab])
|
||||
|
||||
const isLoggedIn = !!(auth?.user)
|
||||
|
||||
@@ -98,9 +110,27 @@ export default function ProfileShow() {
|
||||
? socialLinks.reduce((acc, l) => { acc[l.platform] = l; return acc }, {})
|
||||
: (socialLinks ?? {})
|
||||
|
||||
const contentShellClassName = activeTab === 'artworks'
|
||||
? 'w-full px-4 md:px-6'
|
||||
: activeTab === 'posts'
|
||||
? 'mx-auto max-w-7xl px-4 md:px-6'
|
||||
: 'max-w-6xl mx-auto px-4'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-16">
|
||||
{/* Hero section */}
|
||||
<div className="relative min-h-screen overflow-hidden pb-16">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-90"
|
||||
style={{
|
||||
background: 'radial-gradient(circle at top left, rgba(56,189,248,0.18), transparent 32%), radial-gradient(circle at 82% 10%, rgba(249,115,22,0.16), transparent 28%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #0a1220 100%)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 -z-10 opacity-[0.06]"
|
||||
style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '180px' }}
|
||||
/>
|
||||
|
||||
<ProfileHero
|
||||
user={user}
|
||||
profile={profile}
|
||||
@@ -121,26 +151,20 @@ export default function ProfileShow() {
|
||||
) : null}
|
||||
/>
|
||||
|
||||
{/* Stats pills row */}
|
||||
<ProfileStatsRow
|
||||
stats={stats}
|
||||
followerCount={followerCount}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
<div className="mt-6">
|
||||
<ProfileTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sticky tabs */}
|
||||
<ProfileTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
|
||||
{/* Tab content area */}
|
||||
<div className={activeTab === 'artworks' ? 'w-full px-4 md:px-6' : 'max-w-6xl mx-auto px-4'}>
|
||||
<div className={`${contentShellClassName} pt-6`}>
|
||||
{activeTab === 'artworks' && (
|
||||
<TabArtworks
|
||||
artworks={{ data: artworkList, next_cursor: artworkNextCursor }}
|
||||
featuredArtworks={featuredArtworks}
|
||||
username={user.username || user.name}
|
||||
galleryUrl={galleryUrl}
|
||||
isActive
|
||||
/>
|
||||
)}
|
||||
@@ -156,6 +180,7 @@ export default function ProfileShow() {
|
||||
recentFollowers={recentFollowers}
|
||||
socialLinks={socialLinksObj}
|
||||
countryName={countryName}
|
||||
profileUrl={profileUrl}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
)}
|
||||
@@ -175,9 +200,16 @@ export default function ProfileShow() {
|
||||
<TabAbout
|
||||
user={user}
|
||||
profile={profile}
|
||||
stats={stats}
|
||||
achievements={achievements}
|
||||
artworks={artworkList}
|
||||
creatorStories={creatorStories}
|
||||
profileComments={profileComments}
|
||||
socialLinks={socialLinksObj}
|
||||
countryName={countryName}
|
||||
followerCount={followerCount}
|
||||
recentFollowers={recentFollowers}
|
||||
leaderboardRank={leaderboardRank}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'stats' && (
|
||||
|
||||
@@ -606,14 +606,73 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
|
||||
|
||||
if (uploadsV2Enabled) {
|
||||
return (
|
||||
<section className="px-4 py-1">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<UploadWizard
|
||||
initialDraftId={draftId ?? null}
|
||||
chunkSize={chunkSize}
|
||||
contentTypes={Array.isArray(props?.content_types) ? props.content_types : []}
|
||||
suggestedTags={Array.isArray(props?.suggested_tags) ? props.suggested_tags : []}
|
||||
/>
|
||||
<section className="min-h-[calc(100vh-4rem)] bg-[#07111c] text-slate-100">
|
||||
<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
|
||||
initialDraftId={draftId ?? null}
|
||||
chunkSize={chunkSize}
|
||||
contentTypes={Array.isArray(props?.content_types) ? props.content_types : []}
|
||||
suggestedTags={Array.isArray(props?.suggested_tags) ? props.suggested_tags : []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
52
resources/js/bootstrap.js
vendored
52
resources/js/bootstrap.js
vendored
@@ -1,9 +1,51 @@
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
import axios from '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) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function PostActions({
|
||||
}
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const url = `${window.location.origin}/@${post.author.username}?tab=posts&post=${post.id}`
|
||||
const url = `${window.location.origin}/@${post.author.username}/posts?post=${post.id}`
|
||||
navigator.clipboard?.writeText(url)
|
||||
setShareMsg('Link copied!')
|
||||
setTimeout(() => setShareMsg(null), 2000)
|
||||
|
||||
@@ -1,67 +1,28 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
const COLLAPSE_AT = 560
|
||||
|
||||
function renderMarkdownSafe(text) {
|
||||
const lines = text.split(/\n{2,}/)
|
||||
|
||||
return lines.map((line, lineIndex) => {
|
||||
const parts = []
|
||||
let rest = line
|
||||
let key = 0
|
||||
const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g
|
||||
|
||||
let match = linkPattern.exec(rest)
|
||||
let lastIndex = 0
|
||||
while (match) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(<span key={`txt-${lineIndex}-${key++}`}>{rest.slice(lastIndex, match.index)}</span>)
|
||||
}
|
||||
|
||||
parts.push(
|
||||
<a
|
||||
key={`lnk-${lineIndex}-${key++}`}
|
||||
href={match[2]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{match[1]}
|
||||
</a>,
|
||||
)
|
||||
|
||||
lastIndex = match.index + match[0].length
|
||||
match = linkPattern.exec(rest)
|
||||
}
|
||||
|
||||
if (lastIndex < rest.length) {
|
||||
parts.push(<span key={`txt-${lineIndex}-${key++}`}>{rest.slice(lastIndex)}</span>)
|
||||
}
|
||||
|
||||
return (
|
||||
<p key={`p-${lineIndex}`} className="text-sm leading-7 text-white/50">
|
||||
{parts}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export default function ArtworkDescription({ artwork }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const content = (artwork?.description || '').trim()
|
||||
const contentHtml = (artwork?.description_html || '').trim()
|
||||
const collapsed = content.length > COLLAPSE_AT && !expanded
|
||||
const visibleText = collapsed ? `${content.slice(0, COLLAPSE_AT)}…` : content
|
||||
// useMemo must always be called (Rules of Hooks) — guard inside the callback
|
||||
const rendered = useMemo(
|
||||
() => (content.length > 0 ? renderMarkdownSafe(visibleText) : null),
|
||||
[content, visibleText],
|
||||
)
|
||||
|
||||
if (content.length === 0) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="max-w-[720px] space-y-3 text-sm leading-7 text-white/50">{rendered}</div>
|
||||
<div
|
||||
className={[
|
||||
'max-w-[720px] overflow-hidden transition-[max-height] duration-300',
|
||||
collapsed ? 'max-h-[11.5rem]' : 'max-h-[100rem]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-sm leading-7 prose-p:my-3 prose-p:text-white/50 prose-a:text-accent prose-a:no-underline hover:prose-a:underline prose-strong:text-white/80 prose-em:text-white/70 prose-code:text-white/80"
|
||||
dangerouslySetInnerHTML={{ __html: contentHtml }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{content.length > COLLAPSE_AT && (
|
||||
<button
|
||||
|
||||
@@ -97,12 +97,32 @@ function slugify(text) {
|
||||
}
|
||||
|
||||
function stripHtml(html) {
|
||||
const decodeEntities = (value) => {
|
||||
let decoded = String(value ?? '')
|
||||
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
if (!decoded.includes('&')) break
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.innerHTML = decoded
|
||||
const next = textarea.value
|
||||
if (next === decoded) break
|
||||
decoded = next
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return decoded
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const div = document.createElement('div')
|
||||
div.innerHTML = html
|
||||
div.innerHTML = decodeEntities(html)
|
||||
return div.textContent || div.innerText || ''
|
||||
}
|
||||
return html.replace(/<[^>]*>/g, '')
|
||||
return decodeEntities(html).replace(/<[^>]*>/g, '')
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function ConversationList({ conversations, loading, activeId, currentUserId, onSelect }) {
|
||||
export default function ConversationList({ conversations, loading, activeId, currentUserId, onlineUserIds = [], typingByConversation = {}, onSelect }) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3">
|
||||
@@ -13,7 +13,7 @@ export default function ConversationList({ conversations, loading, activeId, cur
|
||||
</span>
|
||||
</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 ? (
|
||||
<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}
|
||||
@@ -28,6 +28,8 @@ export default function ConversationList({ conversations, loading, activeId, cur
|
||||
conv={conversation}
|
||||
isActive={conversation.id === activeId}
|
||||
currentUserId={currentUserId}
|
||||
onlineUserIds={onlineUserIds}
|
||||
typingUsers={typingByConversation[conversation.id] ?? []}
|
||||
onClick={() => onSelect(conversation.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -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 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 myParticipant = conv.all_participants?.find((participant) => participant.user_id === currentUserId)
|
||||
const otherParticipant = conv.all_participants?.find((participant) => participant.user_id !== currentUserId)
|
||||
const isArchived = myParticipant?.is_archived ?? false
|
||||
const isPinned = myParticipant?.is_pinned ?? false
|
||||
const activeMembers = conv.all_participants?.filter((participant) => !participant.left_at).length ?? 0
|
||||
const typeLabel = conv.type === 'group' ? `${activeMembers} members` : 'Direct message'
|
||||
const onlineMembers = conv.type === 'group'
|
||||
? conv.all_participants?.filter((participant) => participant.user_id !== currentUserId && onlineUserIds.includes(Number(participant.user_id)) && !participant.left_at).length ?? 0
|
||||
: 0
|
||||
const isDirectOnline = conv.type === 'direct' && otherParticipant ? onlineUserIds.includes(Number(otherParticipant.user_id)) : false
|
||||
const typeLabel = conv.type === 'group'
|
||||
? (onlineMembers > 0 ? `${onlineMembers} online` : `${activeMembers} members`)
|
||||
: (isDirectOnline ? 'Online now' : 'Direct message')
|
||||
const senderLabel = lastMsg?.sender?.username ? `@${lastMsg.sender.username}` : null
|
||||
const initials = label
|
||||
.split(/\s+/)
|
||||
@@ -61,8 +72,11 @@ 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' : ''}`}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[0_10px_25px_rgba(0,0,0,0.18)] ${conv.type === 'group' ? 'bg-gradient-to-br from-fuchsia-500 to-violet-600' : 'bg-gradient-to-br from-sky-500 to-cyan-500'}`}>
|
||||
{initials}
|
||||
<div className="relative shrink-0">
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[0_10px_25px_rgba(0,0,0,0.18)] ${conv.type === 'group' ? 'bg-gradient-to-br from-fuchsia-500 to-violet-600' : 'bg-gradient-to-br from-sky-500 to-cyan-500'}`}>
|
||||
{initials}
|
||||
</div>
|
||||
{isDirectOnline ? <span className="absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full border-2 border-[#0a101a] bg-emerald-300 shadow-[0_0_0_6px_rgba(16,185,129,0.08)]" /> : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -89,8 +103,11 @@ function ConversationRow({ conv, isActive, currentUserId, onClick }) {
|
||||
</div>
|
||||
|
||||
<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}
|
||||
<p className="mt-1 truncate text-sm text-white/62">{preview}</p>
|
||||
{typingUsers.length === 0 && senderLabel ? <p className="text-[11px] text-white/35">{senderLabel}</p> : null}
|
||||
<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>
|
||||
@@ -110,6 +127,23 @@ function truncate(str, max) {
|
||||
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) {
|
||||
if (!iso) return 'No activity'
|
||||
const diff = (Date.now() - new Date(iso).getTime()) / 1000
|
||||
|
||||
@@ -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'
|
||||
|
||||
export default function ConversationThread({
|
||||
conversationId,
|
||||
conversation,
|
||||
realtimeEnabled,
|
||||
realtimeStatus,
|
||||
currentUserId,
|
||||
currentUsername,
|
||||
onlineUserIds,
|
||||
apiFetch,
|
||||
onBack,
|
||||
onMarkRead,
|
||||
onConversationUpdated,
|
||||
onConversationPatched,
|
||||
}) {
|
||||
const [messages, setMessages] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -21,6 +24,8 @@ export default function ConversationThread({
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [typingUsers, setTypingUsers] = useState([])
|
||||
const [participantState, setParticipantState] = useState(conversation?.all_participants ?? [])
|
||||
const [presenceUsers, setPresenceUsers] = useState([])
|
||||
const [threadSearch, setThreadSearch] = useState('')
|
||||
const [busyAction, setBusyAction] = useState(null)
|
||||
const [lightbox, setLightbox] = useState(null)
|
||||
@@ -29,8 +34,15 @@ export default function ConversationThread({
|
||||
const fileInputRef = useRef(null)
|
||||
const typingRef = useRef(null)
|
||||
const stopTypingRef = useRef(null)
|
||||
const readReceiptRef = useRef(null)
|
||||
const typingExpiryTimersRef = useRef(new Map())
|
||||
const messagesRef = useRef([])
|
||||
const lastStartRef = useRef(0)
|
||||
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 animatedMessageIdsRef = useRef(new Set())
|
||||
const [animatedMessageIds, setAnimatedMessageIds] = useState({})
|
||||
@@ -38,16 +50,29 @@ export default function ConversationThread({
|
||||
|
||||
const myParticipant = useMemo(() => (
|
||||
conversation?.my_participant
|
||||
?? conversation?.all_participants?.find((participant) => participant.user_id === currentUserId)
|
||||
?? participantState.find((participant) => participant.user_id === currentUserId)
|
||||
?? null
|
||||
), [conversation, currentUserId])
|
||||
), [conversation, currentUserId, participantState])
|
||||
|
||||
const participants = useMemo(() => conversation?.all_participants ?? [], [conversation])
|
||||
const participants = useMemo(() => participantState, [participantState])
|
||||
const participantNames = useMemo(() => (
|
||||
participants
|
||||
.map((participant) => participant.user?.username)
|
||||
.filter(Boolean)
|
||||
), [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 query = threadSearch.trim().toLowerCase()
|
||||
@@ -66,10 +91,66 @@ export default function ConversationThread({
|
||||
return participants.find((participant) => participant.user_id !== currentUserId)?.user?.username ?? 'Direct message'
|
||||
}, [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 } = {}) => {
|
||||
if (append) setLoadingMore(true)
|
||||
else if (!silent) setLoading(true)
|
||||
|
||||
if (append && listRef.current) {
|
||||
previousScrollHeightRef.current = listRef.current.scrollHeight
|
||||
pendingPrependRef.current = true
|
||||
}
|
||||
|
||||
try {
|
||||
const url = cursor
|
||||
? `/api/messages/${conversationId}?cursor=${encodeURIComponent(cursor)}`
|
||||
@@ -96,30 +177,76 @@ export default function ConversationThread({
|
||||
}
|
||||
}, [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(() => {
|
||||
initialLoadRef.current = true
|
||||
shouldStickToBottomRef.current = true
|
||||
previousScrollHeightRef.current = 0
|
||||
pendingPrependRef.current = false
|
||||
pendingComposerScrollRef.current = false
|
||||
knownMessageIdsRef.current = new Set()
|
||||
animatedMessageIdsRef.current = new Set()
|
||||
setMessages([])
|
||||
setPresenceUsers([])
|
||||
setTypingUsers([])
|
||||
setNextCursor(null)
|
||||
setBody('')
|
||||
setFiles([])
|
||||
setDraftTitle(conversation?.title ?? '')
|
||||
loadMessages()
|
||||
loadTyping()
|
||||
}, [conversation?.title, conversationId, loadMessages, loadTyping])
|
||||
if (!realtimeEnabled) {
|
||||
loadTyping()
|
||||
}
|
||||
}, [conversationId, loadMessages, loadTyping, realtimeEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch(`/api/messages/${conversationId}/read`, { method: 'POST' })
|
||||
.then(() => onMarkRead?.(conversationId))
|
||||
.catch(() => {})
|
||||
}, [apiFetch, conversationId, onMarkRead])
|
||||
setParticipantState(conversation?.all_participants ?? [])
|
||||
setDraftTitle(conversation?.title ?? '')
|
||||
}, [conversation?.all_participants, conversation?.title])
|
||||
|
||||
useEffect(() => {
|
||||
markConversationRead()
|
||||
}, [markConversationRead])
|
||||
|
||||
useEffect(() => {
|
||||
if (realtimeEnabled) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
loadTyping()
|
||||
if (!realtimeEnabled) {
|
||||
loadMessages({ silent: true })
|
||||
}
|
||||
}, realtimeEnabled ? 5000 : 8000)
|
||||
loadMessages({ silent: true })
|
||||
}, 8000)
|
||||
|
||||
return () => window.clearInterval(timer)
|
||||
}, [loadMessages, loadTyping, realtimeEnabled])
|
||||
@@ -127,21 +254,176 @@ export default function ConversationThread({
|
||||
useEffect(() => () => {
|
||||
if (typingRef.current) window.clearTimeout(typingRef.current)
|
||||
if (stopTypingRef.current) window.clearTimeout(stopTypingRef.current)
|
||||
if (readReceiptRef.current) window.clearTimeout(readReceiptRef.current)
|
||||
typingExpiryTimersRef.current.forEach((timer) => window.clearTimeout(timer))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return
|
||||
if (initialLoadRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight
|
||||
initialLoadRef.current = false
|
||||
messagesRef.current = messages
|
||||
}, [messages])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = listRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
const nearBottom = listRef.current.scrollHeight - listRef.current.scrollTop - listRef.current.clientHeight < 180
|
||||
if (nearBottom) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight
|
||||
if (initialLoadRef.current) {
|
||||
scrollToBottom()
|
||||
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(() => {
|
||||
const known = knownMessageIdsRef.current
|
||||
@@ -183,6 +465,12 @@ export default function ConversationThread({
|
||||
const handleBodyChange = useCallback((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()
|
||||
if (now - lastStartRef.current > 2500) {
|
||||
lastStartRef.current = now
|
||||
@@ -208,8 +496,10 @@ export default function ConversationThread({
|
||||
if (!trimmed && files.length === 0) return
|
||||
|
||||
const optimisticId = `optimistic-${Date.now()}`
|
||||
const clientTempId = `tmp_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
|
||||
const optimisticMessage = normalizeMessage({
|
||||
id: optimisticId,
|
||||
client_temp_id: clientTempId,
|
||||
body: trimmed,
|
||||
sender: { id: currentUserId, username: currentUsername },
|
||||
sender_id: currentUserId,
|
||||
@@ -223,6 +513,8 @@ export default function ConversationThread({
|
||||
_optimistic: true,
|
||||
}, currentUserId)
|
||||
|
||||
pendingComposerScrollRef.current = true
|
||||
shouldStickToBottomRef.current = true
|
||||
setMessages((prev) => mergeMessageLists(prev, [optimisticMessage]))
|
||||
setBody('')
|
||||
setFiles([])
|
||||
@@ -232,6 +524,7 @@ export default function ConversationThread({
|
||||
|
||||
const formData = new FormData()
|
||||
if (trimmed) formData.append('body', trimmed)
|
||||
formData.append('client_temp_id', clientTempId)
|
||||
files.forEach((file) => formData.append('attachments[]', file))
|
||||
|
||||
try {
|
||||
@@ -241,10 +534,10 @@ export default function ConversationThread({
|
||||
})
|
||||
|
||||
const normalized = normalizeMessage(created, currentUserId)
|
||||
setMessages((prev) => prev.map((message) => message.id === optimisticId ? normalized : message))
|
||||
onConversationUpdated?.()
|
||||
setMessages((prev) => mergeMessageLists(prev, [normalized]))
|
||||
patchLastMessage(normalized, { unread_count: 0 })
|
||||
} 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)
|
||||
setFiles(files)
|
||||
setError(err.message)
|
||||
@@ -252,7 +545,7 @@ export default function ConversationThread({
|
||||
setSending(false)
|
||||
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) => {
|
||||
setMessages((prev) => prev.map((message) => {
|
||||
@@ -289,8 +582,8 @@ export default function ConversationThread({
|
||||
setMessages((prev) => prev.map((message) => (
|
||||
message.id === messageId ? normalizeMessage({ ...message, ...updated }, currentUserId) : message
|
||||
)))
|
||||
onConversationUpdated?.()
|
||||
}, [apiFetch, currentUserId, onConversationUpdated])
|
||||
patchLastMessage(normalizeMessage(updated, currentUserId))
|
||||
}, [apiFetch, currentUserId, patchLastMessage])
|
||||
|
||||
const handleDelete = useCallback(async (messageId) => {
|
||||
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
|
||||
)))
|
||||
onConversationUpdated?.()
|
||||
}, [apiFetch, onConversationUpdated])
|
||||
patchConversation({ last_message_at: new Date().toISOString() })
|
||||
}, [apiFetch, patchConversation])
|
||||
|
||||
const runConversationAction = useCallback(async (action, url, apply) => {
|
||||
setBusyAction(action)
|
||||
@@ -308,14 +601,13 @@ export default function ConversationThread({
|
||||
try {
|
||||
const response = await apiFetch(url, { method: action === 'leave' ? 'DELETE' : 'POST' })
|
||||
apply?.(response)
|
||||
onConversationUpdated?.()
|
||||
if (action === 'leave') onBack?.()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setBusyAction(null)
|
||||
}
|
||||
}, [apiFetch, onBack, onConversationUpdated])
|
||||
}, [apiFetch, onBack])
|
||||
|
||||
const handleRename = useCallback(async () => {
|
||||
const title = draftTitle.trim()
|
||||
@@ -329,17 +621,23 @@ export default function ConversationThread({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title }),
|
||||
})
|
||||
onConversationUpdated?.()
|
||||
patchConversation({ title })
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setBusyAction(null)
|
||||
}
|
||||
}, [apiFetch, conversation?.title, conversationId, draftTitle, onConversationUpdated])
|
||||
}, [apiFetch, conversation?.title, conversationId, draftTitle, patchConversation])
|
||||
|
||||
const visibleMessages = filteredMessages
|
||||
const messagesWithDecorators = useMemo(() => decorateMessages(visibleMessages, currentUserId, myParticipant?.last_read_at ?? null), [visibleMessages, currentUserId, myParticipant?.last_read_at])
|
||||
const messagesWithDecorators = useMemo(() => decorateMessages(visibleMessages, currentUserId, myParticipant), [visibleMessages, currentUserId, myParticipant])
|
||||
const typingLabel = buildTypingLabel(typingUsers)
|
||||
const presenceLabel = 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 (
|
||||
<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">
|
||||
<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>}
|
||||
{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_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}
|
||||
@@ -366,12 +665,18 @@ export default function ConversationThread({
|
||||
? `Participants: ${participantNames.join(', ')}`
|
||||
: `Private thread with ${participantNames.find((name) => name !== currentUsername) ?? conversationLabel}.`}
|
||||
</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 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
|
||||
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')}
|
||||
>
|
||||
@@ -380,7 +685,7 @@ export default function ConversationThread({
|
||||
</button>
|
||||
<button
|
||||
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')}
|
||||
>
|
||||
@@ -389,7 +694,10 @@ export default function ConversationThread({
|
||||
</button>
|
||||
<button
|
||||
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')}
|
||||
>
|
||||
@@ -449,7 +757,17 @@ export default function ConversationThread({
|
||||
</div>
|
||||
) : 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 ? (
|
||||
<div className="mb-4 flex justify-center">
|
||||
<button
|
||||
@@ -488,7 +806,7 @@ export default function ConversationThread({
|
||||
const showAvatar = !previous || previous.sender_id !== message.sender_id
|
||||
const endsSequence = !next || next.sender_id !== message.sender_id
|
||||
const seenText = isLastMineMessage(visibleMessages, index, currentUserId)
|
||||
? buildSeenText(participants, currentUserId)
|
||||
? buildSeenText(participants, currentUserId, message)
|
||||
: null
|
||||
|
||||
return (
|
||||
@@ -650,11 +968,66 @@ function summaryToReactionArray(summary, currentUserId) {
|
||||
}
|
||||
|
||||
function mergeMessageLists(existing, incoming) {
|
||||
const map = new Map()
|
||||
for (const message of [...existing, ...incoming]) {
|
||||
map.set(message.id, message)
|
||||
const next = [...existing]
|
||||
|
||||
for (const incomingMessage of incoming) {
|
||||
const existingIndex = next.findIndex((message) => messagesMatch(message, incomingMessage))
|
||||
if (existingIndex >= 0) {
|
||||
next[existingIndex] = {
|
||||
...next[existingIndex],
|
||||
...incomingMessage,
|
||||
_optimistic: false,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
next.push(incomingMessage)
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
||||
|
||||
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) {
|
||||
@@ -674,29 +1047,38 @@ function isLastMineMessage(messages, index, currentUserId) {
|
||||
return true
|
||||
}
|
||||
|
||||
function buildSeenText(participants, currentUserId) {
|
||||
const seenBy = participants
|
||||
.filter((participant) => participant.user_id !== currentUserId && participant.last_read_at)
|
||||
.map((participant) => participant.user?.username)
|
||||
.filter(Boolean)
|
||||
function buildSeenText(participants, currentUserId, message) {
|
||||
const seenBy = participants.filter((participant) => participant.user_id !== currentUserId && participantHasReadMessage(participant, message))
|
||||
|
||||
if (seenBy.length === 0) return 'Sent'
|
||||
if (seenBy.length === 1) return `Seen by @${seenBy[0]}`
|
||||
|
||||
if (seenBy.length === 1) {
|
||||
const readAt = seenBy[0]?.last_read_at
|
||||
return readAt ? `Seen ${formatSeenTime(readAt)}` : 'Seen'
|
||||
}
|
||||
|
||||
return `Seen by ${seenBy.length} people`
|
||||
}
|
||||
|
||||
function decorateMessages(messages, currentUserId, lastReadAt) {
|
||||
function decorateMessages(messages, currentUserId, participant) {
|
||||
let unreadMarked = false
|
||||
const lastReadMessageId = Number(participant?.last_read_message_id ?? 0)
|
||||
const lastReadAt = participant?.last_read_at ?? null
|
||||
|
||||
return messages.map((message, index) => {
|
||||
const previous = messages[index - 1]
|
||||
const currentDay = dayKey(message.created_at)
|
||||
const previousDay = previous ? dayKey(previous.created_at) : null
|
||||
const shouldMarkUnread = !unreadMarked
|
||||
&& !!lastReadAt
|
||||
&& message.sender_id !== currentUserId
|
||||
&& !message.deleted_at
|
||||
&& new Date(message.created_at).getTime() > new Date(lastReadAt).getTime()
|
||||
&& (
|
||||
lastReadMessageId > 0
|
||||
? Number(message.id) > lastReadMessageId
|
||||
: lastReadAt
|
||||
? new Date(message.created_at).getTime() > new Date(lastReadAt).getTime()
|
||||
: true
|
||||
)
|
||||
|
||||
if (shouldMarkUnread) unreadMarked = true
|
||||
|
||||
@@ -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) {
|
||||
const date = new Date(iso)
|
||||
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() {
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)
|
||||
|
||||
|
||||
@@ -9,46 +9,31 @@ const SORT_OPTIONS = [
|
||||
{ value: 'favs', label: 'Most Favourited' },
|
||||
]
|
||||
|
||||
function slugify(str) {
|
||||
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function FeaturedStrip({ featuredArtworks }) {
|
||||
if (!featuredArtworks?.length) return null
|
||||
|
||||
function GalleryToolbar({ sort, onSort }) {
|
||||
return (
|
||||
<div className="mb-7 rounded-2xl border border-white/10 bg-white/[0.02] p-4 md:p-5">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-slate-400">
|
||||
<i className="fa-solid fa-star fa-fw text-amber-400" />
|
||||
Featured
|
||||
</h2>
|
||||
<div className="scrollbar-hide flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2">
|
||||
{featuredArtworks.slice(0, 5).map((art) => (
|
||||
<a
|
||||
key={art.id}
|
||||
href={`/art/${art.id}/${slugify(art.name)}`}
|
||||
className="group w-56 shrink-0 snap-start md:w-64"
|
||||
<div className="mb-5 flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">Sort</span>
|
||||
<div className="flex flex-wrap gap-1 rounded-2xl border border-white/10 bg-white/[0.03] p-1">
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onSort(opt.value)}
|
||||
className={`rounded-xl px-3.5 py-2 text-xs font-medium transition-all ${
|
||||
sort === opt.value
|
||||
? 'bg-sky-500/20 text-sky-300 ring-1 ring-sky-400/40'
|
||||
: 'text-slate-400 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="aspect-[5/3] overflow-hidden rounded-xl bg-black/30 ring-1 ring-white/10 transition-all hover:ring-sky-400/40">
|
||||
<img
|
||||
src={art.thumb}
|
||||
alt={art.name}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 truncate text-sm text-slate-300 transition-colors group-hover:text-white">
|
||||
{art.name}
|
||||
</p>
|
||||
{art.label ? <p className="truncate text-[11px] text-slate-600">{art.label}</p> : null}
|
||||
</a>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProfileGalleryPanel({ artworks, featuredArtworks, username }) {
|
||||
export default function ProfileGalleryPanel({ artworks, username }) {
|
||||
const [sort, setSort] = useState('latest')
|
||||
const [items, setItems] = useState(artworks?.data ?? artworks ?? [])
|
||||
const [nextCursor, setNextCursor] = useState(artworks?.next_cursor ?? null)
|
||||
@@ -74,36 +59,20 @@ export default function ProfileGalleryPanel({ artworks, featuredArtworks, userna
|
||||
|
||||
return (
|
||||
<>
|
||||
<FeaturedStrip featuredArtworks={featuredArtworks} />
|
||||
|
||||
<div className="mb-5 flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">Sort</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => handleSort(opt.value)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
sort === opt.value
|
||||
? 'bg-sky-500/20 text-sky-300 ring-1 ring-sky-400/40'
|
||||
: 'text-slate-400 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mx-auto w-full max-w-6xl px-4 md:px-6">
|
||||
<GalleryToolbar sort={sort} onSort={handleSort} />
|
||||
</div>
|
||||
|
||||
<MasonryGallery
|
||||
key={`profile-${username}-${sort}`}
|
||||
artworks={items}
|
||||
galleryType="profile"
|
||||
cursorEndpoint={`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`}
|
||||
initialNextCursor={nextCursor}
|
||||
limit={24}
|
||||
/>
|
||||
<div className="w-full px-4 md:px-6 xl:px-8">
|
||||
<MasonryGallery
|
||||
key={`profile-${username}-${sort}`}
|
||||
artworks={items}
|
||||
galleryType="profile"
|
||||
cursorEndpoint={`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`}
|
||||
initialNextCursor={nextCursor}
|
||||
limit={24}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import LevelBadge from '../xp/LevelBadge'
|
||||
import XPProgressBar from '../xp/XPProgressBar'
|
||||
import FollowButton from '../social/FollowButton'
|
||||
|
||||
function formatCompactNumber(value) {
|
||||
const numeric = Number(value ?? 0)
|
||||
return numeric.toLocaleString()
|
||||
}
|
||||
|
||||
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
|
||||
const [following, setFollowing] = useState(viewerIsFollowing)
|
||||
const [count, setCount] = useState(followerCount)
|
||||
@@ -17,26 +22,53 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||||
: null
|
||||
const bio = profile?.bio || profile?.about || ''
|
||||
const heroFacts = [
|
||||
{ label: 'Followers', value: formatCompactNumber(count) },
|
||||
{ label: 'Level', value: `Lv ${formatCompactNumber(user?.level ?? 1)}` },
|
||||
{ label: 'Progress', value: `${Math.round(Number(user?.progress_percent ?? 0))}%` },
|
||||
{ label: 'Member since', value: joinDate ?? 'Recently joined' },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-w-6xl mx-auto px-4 pt-4">
|
||||
<div className="relative overflow-hidden rounded-2xl border border-white/10">
|
||||
<div className="relative mx-auto max-w-7xl px-4 pt-4 md:pt-6">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-10 top-8 -z-10 h-44 rounded-full blur-3xl"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, rgba(56,189,248,0.18), rgba(249,115,22,0.14), rgba(59,130,246,0.12))',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative overflow-hidden rounded-[32px] border border-white/10 bg-[#09111f]/80 shadow-[0_24px_80px_rgba(2,6,23,0.55)]">
|
||||
<div
|
||||
className="w-full h-[180px] md:h-[220px] xl:h-[252px]"
|
||||
className="w-full h-[208px] md:h-[248px] xl:h-[288px]"
|
||||
style={{
|
||||
background: coverUrl
|
||||
? `url('${coverUrl}') center ${coverPosition}% / cover no-repeat`
|
||||
: 'linear-gradient(140deg, #0f1724 0%, #101a2a 45%, #0a1220 100%)',
|
||||
: 'linear-gradient(140deg, #07101d 0%, #0b1726 42%, #07111e 100%)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div className="absolute left-4 top-4 z-20 flex flex-wrap items-center gap-2 md:left-6 md:top-6">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-black/30 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-200 backdrop-blur-md">
|
||||
<span className="h-2 w-2 rounded-full bg-sky-400 shadow-[0_0_12px_rgba(56,189,248,0.9)]" />
|
||||
Creator profile
|
||||
</span>
|
||||
{leaderboardRank?.rank ? (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100 backdrop-blur-md">
|
||||
<i className="fa-solid fa-sparkles text-[10px]" />
|
||||
Top #{leaderboardRank.rank} this week
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isOwner ? (
|
||||
<div className="absolute right-3 top-3 z-20">
|
||||
<div className="absolute right-4 top-4 z-20 md:right-6 md:top-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorOpen(true)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-white/20 bg-black/40 px-3 py-2 text-xs font-medium text-white hover:bg-black/60"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-black/35 px-3.5 py-2 text-xs font-medium text-white backdrop-blur-md transition-colors hover:bg-black/55"
|
||||
aria-label="Edit cover image"
|
||||
>
|
||||
<i className="fa-solid fa-image" />
|
||||
@@ -49,148 +81,165 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: coverUrl
|
||||
? 'linear-gradient(to bottom, rgba(0,0,0,0.2), rgba(0,0,0,0.62))'
|
||||
: 'radial-gradient(ellipse at 16% 40%, rgba(77,163,255,.18) 0%, transparent 60%), radial-gradient(ellipse at 84% 22%, rgba(224,122,33,.12) 0%, transparent 54%)',
|
||||
? 'linear-gradient(180deg, rgba(2,6,23,0.16) 0%, rgba(2,6,23,0.28) 38%, rgba(2,6,23,0.9) 100%)'
|
||||
: 'radial-gradient(ellipse at 16% 40%, rgba(77,163,255,.18) 0%, transparent 60%), radial-gradient(ellipse at 84% 22%, rgba(224,122,33,.14) 0%, transparent 54%)',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 opacity-[0.06] pointer-events-none" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '32px' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative -mt-14 md:-mt-16 pb-4 px-1">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:gap-5">
|
||||
<div className="mx-auto z-10 shrink-0 md:mx-0">
|
||||
<img
|
||||
src={user.avatar_url || '/default/avatar_default.webp'}
|
||||
alt={`${uname}'s avatar`}
|
||||
className="h-[104px] w-[104px] rounded-full border-2 border-white/15 object-cover shadow-[0_0_0_6px_rgba(15,23,36,0.95),0_10px_32px_rgba(0,0,0,0.6)] md:h-[116px] md:w-[116px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 text-center md:text-left">
|
||||
<h1 className="text-[28px] font-bold leading-tight tracking-tight text-white md:text-[34px]">
|
||||
{displayName}
|
||||
</h1>
|
||||
<p className="mt-0.5 font-mono text-sm text-slate-400">@{uname}</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center justify-center gap-2 md:justify-start">
|
||||
<LevelBadge level={user?.level} rank={user?.rank} />
|
||||
{leaderboardRank?.rank ? (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-sky-100">
|
||||
Rank #{leaderboardRank.rank} this week
|
||||
</span>
|
||||
) : null}
|
||||
<div className="relative px-4 pb-6 md:px-7 md:pb-7">
|
||||
<div className="relative -mt-16 flex flex-col gap-5 md:-mt-20 md:flex-row md:items-start md:gap-6">
|
||||
<div className="mx-auto z-10 shrink-0 md:mx-0">
|
||||
<img
|
||||
src={user.avatar_url || '/default/avatar_default.webp'}
|
||||
alt={`${uname}'s avatar`}
|
||||
className="h-[112px] w-[112px] rounded-[28px] border border-white/15 bg-[#0b1320] object-cover shadow-[0_0_0_8px_rgba(9,17,31,0.92),0_22px_44px_rgba(2,6,23,0.5)] md:h-[132px] md:w-[132px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center justify-center gap-2.5 text-xs text-slate-400 md:justify-start">
|
||||
{countryName ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1">
|
||||
{profile?.country_code ? (
|
||||
<img
|
||||
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
|
||||
alt={countryName}
|
||||
className="w-4 h-auto rounded-sm"
|
||||
onError={(event) => { event.target.style.display = 'none' }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 text-center md:text-left">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_280px] xl:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 md:justify-start">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">
|
||||
<i className="fa-solid fa-stars text-[10px] text-sky-300" />
|
||||
Profile spotlight
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="mt-3 text-[30px] font-semibold leading-tight tracking-[-0.03em] text-white md:text-[42px]">
|
||||
{displayName}
|
||||
</h1>
|
||||
<p className="mt-1 font-mono text-sm text-slate-400 md:text-[15px]">@{uname}</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2 md:justify-start">
|
||||
<LevelBadge level={user?.level} rank={user?.rank} />
|
||||
{countryName ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300">
|
||||
{profile?.country_code ? (
|
||||
<img
|
||||
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
|
||||
alt={countryName}
|
||||
className="h-auto w-4 rounded-sm"
|
||||
onError={(event) => { event.target.style.display = 'none' }}
|
||||
/>
|
||||
) : null}
|
||||
{countryName}
|
||||
</span>
|
||||
) : null}
|
||||
{joinDate ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-300">
|
||||
<i className="fa-solid fa-calendar-days fa-fw text-slate-500" />
|
||||
Joined {joinDate}
|
||||
</span>
|
||||
) : null}
|
||||
{profile?.website ? (
|
||||
<a
|
||||
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1.5 text-xs text-sky-200 transition-colors hover:border-sky-300/35 hover:bg-sky-400/15"
|
||||
>
|
||||
<i className="fa-solid fa-link fa-fw" />
|
||||
{(() => {
|
||||
try {
|
||||
const url = profile.website.startsWith('http') ? profile.website : `https://${profile.website}`
|
||||
return new URL(url).hostname
|
||||
} catch {
|
||||
return profile.website
|
||||
}
|
||||
})()}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{bio ? (
|
||||
<p className="mx-auto mt-4 max-w-2xl text-sm leading-relaxed text-slate-300/90 md:mx-0 md:text-[15px]">
|
||||
{bio}
|
||||
</p>
|
||||
) : null}
|
||||
{countryName}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{joinDate ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1">
|
||||
<i className="fa-solid fa-calendar-days fa-fw opacity-70" />
|
||||
Joined {joinDate}
|
||||
</span>
|
||||
) : null}
|
||||
<XPProgressBar
|
||||
xp={user?.xp}
|
||||
currentLevelXp={user?.current_level_xp}
|
||||
nextLevelXp={user?.next_level_xp}
|
||||
progressPercent={user?.progress_percent}
|
||||
maxLevel={user?.max_level}
|
||||
className="mt-4 max-w-3xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 xl:pt-1">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 xl:justify-end">
|
||||
{extraActions}
|
||||
{isOwner ? (
|
||||
<>
|
||||
<a
|
||||
href="/dashboard/profile"
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/15 bg-white/[0.04] px-4 py-2.5 text-sm font-medium text-slate-200 transition-all hover:bg-white/[0.08] hover:text-white"
|
||||
aria-label="Edit profile"
|
||||
>
|
||||
<i className="fa-solid fa-pen fa-fw" />
|
||||
Edit Profile
|
||||
</a>
|
||||
<a
|
||||
href="/studio"
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-gradient-to-r from-sky-500 to-cyan-400 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-[0_18px_36px_rgba(14,165,233,0.28)] transition-transform hover:-translate-y-0.5"
|
||||
aria-label="Open Studio"
|
||||
>
|
||||
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
|
||||
Studio
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FollowButton
|
||||
username={uname}
|
||||
initialFollowing={following}
|
||||
initialCount={count}
|
||||
followingClassName="border border-emerald-400/40 bg-emerald-500/12 text-emerald-300 hover:bg-emerald-500/18"
|
||||
idleClassName="border border-sky-400/40 bg-sky-500/12 text-sky-200 hover:bg-sky-500/20"
|
||||
onChange={({ following: nextFollowing, followersCount }) => {
|
||||
setFollowing(nextFollowing)
|
||||
setCount(followersCount)
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (navigator.share) {
|
||||
navigator.share({ title: `${displayName} on Skinbase`, url: window.location.href })
|
||||
} else {
|
||||
navigator.clipboard.writeText(window.location.href)
|
||||
}
|
||||
}}
|
||||
aria-label="Share profile"
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-share-nodes fa-fw" />
|
||||
Share
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-left">
|
||||
{heroFacts.map((fact) => (
|
||||
<div
|
||||
key={fact.label}
|
||||
className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
|
||||
>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">{fact.label}</div>
|
||||
<div className="mt-1.5 text-sm font-semibold tracking-tight text-white md:text-base">{fact.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile?.website ? (
|
||||
<a
|
||||
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-sky-300 transition-colors hover:bg-white/10 hover:text-sky-200"
|
||||
>
|
||||
<i className="fa-solid fa-link fa-fw" />
|
||||
{(() => {
|
||||
try {
|
||||
const url = profile.website.startsWith('http') ? profile.website : `https://${profile.website}`
|
||||
return new URL(url).hostname
|
||||
} catch {
|
||||
return profile.website
|
||||
}
|
||||
})()}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{bio ? (
|
||||
<p className="mx-auto mt-3 max-w-2xl line-clamp-2 text-sm leading-relaxed text-slate-300/90 md:mx-0 md:line-clamp-3">
|
||||
{bio}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<XPProgressBar
|
||||
xp={user?.xp}
|
||||
currentLevelXp={user?.current_level_xp}
|
||||
nextLevelXp={user?.next_level_xp}
|
||||
progressPercent={user?.progress_percent}
|
||||
maxLevel={user?.max_level}
|
||||
className="mt-4 max-w-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center justify-center gap-2 pb-0.5 md:justify-end">
|
||||
{extraActions}
|
||||
{isOwner ? (
|
||||
<>
|
||||
<a
|
||||
href="/dashboard/profile"
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-white/15 px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/5 hover:text-white"
|
||||
aria-label="Edit profile"
|
||||
>
|
||||
<i className="fa-solid fa-pen fa-fw" />
|
||||
Edit Profile
|
||||
</a>
|
||||
<a
|
||||
href="/studio"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-sky-600 px-4 py-2.5 text-sm font-medium text-white shadow-lg shadow-sky-900/30 transition-all hover:bg-sky-500"
|
||||
aria-label="Open Studio"
|
||||
>
|
||||
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
|
||||
Studio
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FollowButton
|
||||
username={uname}
|
||||
initialFollowing={following}
|
||||
initialCount={count}
|
||||
followingClassName="bg-green-500/10 border border-green-400/40 text-green-400 hover:bg-green-500/15"
|
||||
idleClassName="bg-sky-500/10 border border-sky-400/40 text-sky-400 hover:bg-sky-500/20"
|
||||
onChange={({ following: nextFollowing, followersCount }) => {
|
||||
setFollowing(nextFollowing)
|
||||
setCount(followersCount)
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (navigator.share) {
|
||||
navigator.share({ title: `${displayName} on Skinbase`, url: window.location.href })
|
||||
} else {
|
||||
navigator.clipboard.writeText(window.location.href)
|
||||
}
|
||||
}}
|
||||
aria-label="Share profile"
|
||||
className="rounded-xl border border-white/10 p-2.5 text-slate-400 transition-all hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<i className="fa-solid fa-share-nodes fa-fw" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
const PILLS = [
|
||||
{ key: 'uploads_count', label: 'Artworks', icon: 'fa-images', tab: 'artworks' },
|
||||
{ key: 'downloads_received_count', label: 'Downloads', icon: 'fa-download', tab: null },
|
||||
{ key: 'follower_count', label: 'Followers', icon: 'fa-users', tab: 'about' },
|
||||
{ key: 'following_count', label: 'Following', icon: 'fa-user-check', tab: 'about' },
|
||||
{ key: 'artwork_views_received_count', label: 'Views', icon: 'fa-eye', tab: 'stats' },
|
||||
{ key: 'awards_received_count', label: 'Awards', icon: 'fa-trophy', tab: 'stats' },
|
||||
]
|
||||
|
||||
/**
|
||||
* ProfileStatsRow
|
||||
* Horizontal scrollable pill row of stat counts.
|
||||
* Clicking a pill navigates to the relevant tab.
|
||||
*/
|
||||
export default function ProfileStatsRow({ stats, followerCount, onTabChange }) {
|
||||
const values = {
|
||||
uploads_count: stats?.uploads_count ?? 0,
|
||||
downloads_received_count: stats?.downloads_received_count ?? 0,
|
||||
follower_count: followerCount ?? 0,
|
||||
following_count: stats?.following_count ?? 0,
|
||||
artwork_views_received_count: stats?.artwork_views_received_count ?? 0,
|
||||
awards_received_count: stats?.awards_received_count ?? 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-white/10" style={{ background: 'rgba(255,255,255,0.02)' }}>
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="grid grid-cols-3 md:grid-cols-6 gap-2 py-3">
|
||||
{PILLS.map((pill) => (
|
||||
<button
|
||||
key={pill.key}
|
||||
onClick={() => pill.tab && onTabChange(pill.tab)}
|
||||
title={pill.label}
|
||||
disabled={!pill.tab}
|
||||
className={`
|
||||
flex flex-col items-center justify-center gap-1 px-2 py-3 rounded-xl text-sm transition-all text-center
|
||||
border border-white/10 bg-white/[0.02]
|
||||
${pill.tab
|
||||
? 'cursor-pointer hover:bg-white/[0.06] hover:border-white/20 hover:text-white text-slate-300 group'
|
||||
: 'cursor-default text-slate-400 opacity-90'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<i className={`fa-solid ${pill.icon} fa-fw text-xs ${pill.tab ? 'opacity-70 group-hover:opacity-100' : 'opacity-60'}`} />
|
||||
<span className="font-bold text-white tabular-nums text-base leading-none">
|
||||
{Number(values[pill.key]).toLocaleString()}
|
||||
</span>
|
||||
<span className="text-slate-500 text-[11px] uppercase tracking-wide leading-none">{pill.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
|
||||
export const TABS = [
|
||||
{ id: 'posts', label: 'Posts', icon: 'fa-newspaper' },
|
||||
{ id: 'artworks', label: 'Artworks', icon: 'fa-images' },
|
||||
{ id: 'stories', label: 'Stories', icon: 'fa-feather-pointed' },
|
||||
{ id: 'achievements', label: 'Achievements', icon: 'fa-trophy' },
|
||||
{ id: 'posts', label: 'Posts', icon: 'fa-newspaper' },
|
||||
{ id: 'collections', label: 'Collections', icon: 'fa-layer-group' },
|
||||
{ id: 'about', label: 'About', icon: 'fa-id-card' },
|
||||
{ id: 'stats', label: 'Stats', icon: 'fa-chart-bar' },
|
||||
@@ -23,7 +23,6 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
|
||||
const navRef = useRef(null)
|
||||
const activeRef = useRef(null)
|
||||
|
||||
// Scroll active tab into view on mount/change
|
||||
useEffect(() => {
|
||||
if (activeRef.current && navRef.current) {
|
||||
activeRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
|
||||
@@ -31,13 +30,14 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
|
||||
}, [activeTab])
|
||||
|
||||
return (
|
||||
<nav
|
||||
ref={navRef}
|
||||
className="profile-tabs-sticky sticky z-30 bg-[#0c1525]/95 backdrop-blur-xl border-b border-white/10 overflow-x-auto scrollbar-hide"
|
||||
aria-label="Profile sections"
|
||||
role="tablist"
|
||||
>
|
||||
<div className="max-w-6xl mx-auto px-3 flex gap-1 py-1 min-w-max sm:min-w-0">
|
||||
<div className="sticky top-0 z-30 border-b border-white/10 bg-[#08111f]/80 backdrop-blur-2xl">
|
||||
<nav
|
||||
ref={navRef}
|
||||
className="profile-tabs-sticky overflow-x-auto scrollbar-hide"
|
||||
aria-label="Profile sections"
|
||||
role="tablist"
|
||||
>
|
||||
<div className="mx-auto flex w-max min-w-full gap-2 px-3 py-3 justify-center xl:items-stretch">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = activeTab === tab.id
|
||||
return (
|
||||
@@ -49,28 +49,29 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
|
||||
aria-selected={isActive}
|
||||
aria-controls={`tabpanel-${tab.id}`}
|
||||
className={`
|
||||
relative flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap rounded-lg
|
||||
transition-colors duration-150 outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-sky-400/70 rounded-t
|
||||
group relative flex items-center gap-2.5 rounded-2xl border px-3.5 py-3 text-sm font-medium whitespace-nowrap
|
||||
outline-none transition-all duration-150 focus-visible:ring-2 focus-visible:ring-sky-400/70
|
||||
${isActive
|
||||
? 'text-white bg-white/[0.05]'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-white/[0.03]'
|
||||
? 'border-sky-300/25 bg-gradient-to-br from-sky-400/18 via-white/[0.06] to-cyan-400/10 text-white shadow-[0_16px_32px_rgba(14,165,233,0.12)]'
|
||||
: 'border-white/8 bg-white/[0.03] text-slate-400 hover:border-white/15 hover:bg-white/[0.05] hover:text-slate-100'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<i className={`fa-solid ${tab.icon} fa-fw text-xs ${isActive ? 'text-sky-400' : 'opacity-75'}`} />
|
||||
<span className={`inline-flex h-9 w-9 items-center justify-center rounded-xl border text-sm ${isActive ? 'border-sky-300/20 bg-sky-400/10 text-sky-200' : 'border-white/10 bg-white/[0.04] text-slate-500 group-hover:text-slate-300'}`}>
|
||||
<i className={`fa-solid ${tab.icon} fa-fw`} />
|
||||
</span>
|
||||
{tab.label}
|
||||
{/* Active indicator bar */}
|
||||
{isActive && (
|
||||
<span
|
||||
className="absolute bottom-0 inset-x-0 h-0.5 rounded-full bg-sky-400 shadow-[0_0_8px_rgba(56,189,248,0.6)]"
|
||||
className="absolute inset-x-4 bottom-0 h-0.5 rounded-full bg-sky-300 shadow-[0_0_10px_rgba(125,211,252,0.8)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,100 @@ const SOCIAL_ICONS = {
|
||||
website: { icon: 'fa-solid fa-link', label: 'Website' },
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value ?? 0).toLocaleString()
|
||||
}
|
||||
|
||||
function formatRelativeDate(value) {
|
||||
if (!value) return null
|
||||
|
||||
try {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
|
||||
const now = new Date()
|
||||
const diffSeconds = Math.round((date.getTime() - now.getTime()) / 1000)
|
||||
const absSeconds = Math.abs(diffSeconds)
|
||||
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
||||
|
||||
if (absSeconds < 3600) {
|
||||
return formatter.format(Math.round(diffSeconds / 60), 'minute')
|
||||
}
|
||||
|
||||
if (absSeconds < 86400) {
|
||||
return formatter.format(Math.round(diffSeconds / 3600), 'hour')
|
||||
}
|
||||
|
||||
if (absSeconds < 604800) {
|
||||
return formatter.format(Math.round(diffSeconds / 86400), 'day')
|
||||
}
|
||||
|
||||
if (absSeconds < 2629800) {
|
||||
return formatter.format(Math.round(diffSeconds / 604800), 'week')
|
||||
}
|
||||
|
||||
return formatter.format(Math.round(diffSeconds / 2629800), 'month')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatShortDate(value) {
|
||||
if (!value) return null
|
||||
|
||||
try {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function truncateText(value, maxLength = 140) {
|
||||
const text = String(value ?? '').trim()
|
||||
if (!text) return ''
|
||||
if (text.length <= maxLength) return text
|
||||
|
||||
return `${text.slice(0, maxLength).trimEnd()}...`
|
||||
}
|
||||
|
||||
function buildInterestGroups(artworks = []) {
|
||||
const categoryMap = new Map()
|
||||
const contentTypeMap = new Map()
|
||||
|
||||
artworks.forEach((artwork) => {
|
||||
const categoryKey = String(artwork?.category_slug || artwork?.category || '').trim().toLowerCase()
|
||||
const categoryLabel = String(artwork?.category || '').trim()
|
||||
const contentTypeKey = String(artwork?.content_type_slug || artwork?.content_type || '').trim().toLowerCase()
|
||||
const contentTypeLabel = String(artwork?.content_type || '').trim()
|
||||
|
||||
if (categoryKey && categoryLabel) {
|
||||
categoryMap.set(categoryKey, {
|
||||
label: categoryLabel,
|
||||
count: (categoryMap.get(categoryKey)?.count ?? 0) + 1,
|
||||
})
|
||||
}
|
||||
|
||||
if (contentTypeKey && contentTypeLabel) {
|
||||
contentTypeMap.set(contentTypeKey, {
|
||||
label: contentTypeLabel,
|
||||
count: (contentTypeMap.get(contentTypeKey)?.count ?? 0) + 1,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const toSortedList = (source) => Array.from(source.values())
|
||||
.sort((left, right) => right.count - left.count || left.label.localeCompare(right.label))
|
||||
.slice(0, 5)
|
||||
|
||||
return {
|
||||
categories: toSortedList(categoryMap),
|
||||
contentTypes: toSortedList(contentTypeMap),
|
||||
}
|
||||
}
|
||||
|
||||
function InfoRow({ icon, label, children }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2.5 border-b border-white/5 last:border-0">
|
||||
@@ -22,11 +116,47 @@ function InfoRow({ icon, label, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value, tone = 'sky' }) {
|
||||
const tones = {
|
||||
sky: 'text-sky-300 bg-sky-400/10 border-sky-300/15',
|
||||
amber: 'text-amber-200 bg-amber-300/10 border-amber-300/15',
|
||||
emerald: 'text-emerald-200 bg-emerald-400/10 border-emerald-300/15',
|
||||
violet: 'text-violet-200 bg-violet-400/10 border-violet-300/15',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4 shadow-[0_18px_44px_rgba(2,6,23,0.18)]">
|
||||
<div className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border ${tones[tone] || tones.sky}`}>
|
||||
<i className={`fa-solid ${icon}`} />
|
||||
</div>
|
||||
<div className="mt-4 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold tracking-tight text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionCard({ icon, eyebrow, title, children, className = '' }) {
|
||||
return (
|
||||
<section className={`rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_52px_rgba(2,6,23,0.18)] md:p-6 ${className}`.trim()}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.05] text-sky-300">
|
||||
<i className={`${icon} text-base`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">{eyebrow}</p>
|
||||
<h2 className="mt-1 text-xl font-semibold tracking-[-0.02em] text-white md:text-2xl">{title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* TabAbout
|
||||
* Bio, social links, metadata - replaces old sidebar profile card.
|
||||
*/
|
||||
export default function TabAbout({ user, profile, socialLinks, countryName, followerCount }) {
|
||||
export default function TabAbout({ user, profile, stats, achievements, artworks, creatorStories, profileComments, socialLinks, countryName, followerCount, recentFollowers, leaderboardRank }) {
|
||||
const uname = user.username || user.name
|
||||
const displayName = user.name || uname
|
||||
const about = profile?.about
|
||||
@@ -47,119 +177,344 @@ export default function TabAbout({ user, profile, socialLinks, countryName, foll
|
||||
|
||||
const genderMap = { M: 'Male', F: 'Female', X: 'Non-binary / N/A' }
|
||||
const genderLabel = genderMap[profile?.gender?.toUpperCase()] ?? null
|
||||
const birthDate = profile?.birthdate
|
||||
? (() => {
|
||||
try {
|
||||
return new Date(profile.birthdate).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
|
||||
} catch { return null }
|
||||
})()
|
||||
: null
|
||||
const lastSeenRelative = formatRelativeDate(user.last_visit_at)
|
||||
|
||||
const socialEntries = socialLinks
|
||||
? Object.entries(socialLinks).filter(([, link]) => link?.url)
|
||||
: []
|
||||
const followers = recentFollowers ?? []
|
||||
const recentAchievements = Array.isArray(achievements?.recent) ? achievements.recent : []
|
||||
const stories = Array.isArray(creatorStories) ? creatorStories : []
|
||||
const comments = Array.isArray(profileComments) ? profileComments : []
|
||||
const interestGroups = buildInterestGroups(Array.isArray(artworks) ? artworks : [])
|
||||
const summaryCards = [
|
||||
{ icon: 'fa-user-group', label: 'Followers', value: formatNumber(followerCount), tone: 'sky' },
|
||||
{ icon: 'fa-images', label: 'Uploads', value: formatNumber(stats?.uploads_count ?? 0), tone: 'violet' },
|
||||
{ icon: 'fa-eye', label: 'Profile views', value: formatNumber(stats?.profile_views_count ?? 0), tone: 'emerald' },
|
||||
{ icon: 'fa-trophy', label: 'Weekly rank', value: leaderboardRank?.rank ? `#${formatNumber(leaderboardRank.rank)}` : 'Unranked', tone: 'amber' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-about"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-about"
|
||||
className="pt-6 max-w-2xl"
|
||||
className="mx-auto max-w-7xl px-4 pt-4 pb-10 md:px-6"
|
||||
>
|
||||
{/* Bio */}
|
||||
{about ? (
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 mb-5 shadow-xl shadow-black/20 backdrop-blur">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2">
|
||||
<i className="fa-solid fa-quote-left text-purple-400 fa-fw" />
|
||||
About
|
||||
</h2>
|
||||
<p className="text-sm text-slate-300 leading-relaxed whitespace-pre-line">{about}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 mb-5 text-center text-slate-500 text-sm">
|
||||
No bio yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info card */}
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 mb-5 shadow-xl shadow-black/20">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2">
|
||||
<i className="fa-solid fa-id-card text-sky-400 fa-fw" />
|
||||
Profile Info
|
||||
</h2>
|
||||
<div className="divide-y divide-white/5">
|
||||
{displayName && displayName !== uname && (
|
||||
<InfoRow icon="fa-user" label="Display name">{displayName}</InfoRow>
|
||||
)}
|
||||
<InfoRow icon="fa-at" label="Username">
|
||||
<span className="font-mono">@{uname}</span>
|
||||
</InfoRow>
|
||||
{genderLabel && (
|
||||
<InfoRow icon="fa-venus-mars" label="Gender">{genderLabel}</InfoRow>
|
||||
)}
|
||||
{countryName && (
|
||||
<InfoRow icon="fa-earth-americas" label="Country">
|
||||
<span className="flex items-center gap-2">
|
||||
{profile?.country_code && (
|
||||
<img
|
||||
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
|
||||
alt={countryName}
|
||||
className="w-4 h-auto rounded-sm"
|
||||
onError={(e) => { e.target.style.display = 'none' }}
|
||||
/>
|
||||
)}
|
||||
{countryName}
|
||||
</span>
|
||||
</InfoRow>
|
||||
)}
|
||||
{website && (
|
||||
<InfoRow icon="fa-link" label="Website">
|
||||
<a
|
||||
href={website.startsWith('http') ? website : `https://${website}`}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className="text-sky-400 hover:text-sky-300 hover:underline transition-colors"
|
||||
>
|
||||
{(() => {
|
||||
try {
|
||||
const url = website.startsWith('http') ? website : `https://${website}`
|
||||
return new URL(url).hostname
|
||||
} catch { return website }
|
||||
})()}
|
||||
</a>
|
||||
</InfoRow>
|
||||
)}
|
||||
{joinDate && (
|
||||
<InfoRow icon="fa-calendar-days" label="Member since">{joinDate}</InfoRow>
|
||||
)}
|
||||
{lastVisit && (
|
||||
<InfoRow icon="fa-clock" label="Last seen">{lastVisit}</InfoRow>
|
||||
)}
|
||||
<InfoRow icon="fa-users" label="Followers">{Number(followerCount).toLocaleString()}</InfoRow>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryCards.map((card) => (
|
||||
<StatCard key={card.label} {...card} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Social links */}
|
||||
{socialEntries.length > 0 && (
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2">
|
||||
<i className="fa-solid fa-share-nodes text-sky-400 fa-fw" />
|
||||
Social Links
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{socialEntries.map(([platform, link]) => {
|
||||
const si = SOCIAL_ICONS[platform] ?? { icon: 'fa-solid fa-link', label: platform }
|
||||
const href = link.url.startsWith('http') ? link.url : `https://${link.url}`
|
||||
return (
|
||||
<a
|
||||
key={platform}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm border border-white/10 text-slate-300 hover:text-white hover:bg-white/8 hover:border-sky-400/30 transition-all"
|
||||
aria-label={si.label}
|
||||
>
|
||||
<i className={`${si.icon} fa-fw`} />
|
||||
<span>{si.label}</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_380px]">
|
||||
<div className="space-y-6">
|
||||
<SectionCard icon="fa-solid fa-circle-info" eyebrow="Profile story" title={`About ${displayName}`} className="bg-[linear-gradient(135deg,rgba(56,189,248,0.08),rgba(255,255,255,0.04),rgba(249,115,22,0.05))]">
|
||||
{about ? (
|
||||
<p className="whitespace-pre-line text-[15px] leading-8 text-slate-200/90">{about}</p>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-5 py-8 text-center text-sm text-slate-400">
|
||||
This creator has not written a public bio yet.
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon="fa-solid fa-address-card" eyebrow="Details" title="Profile information">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{displayName && displayName !== uname ? (
|
||||
<InfoRow icon="fa-user" label="Display name">{displayName}</InfoRow>
|
||||
) : null}
|
||||
<InfoRow icon="fa-at" label="Username"><span className="font-mono">@{uname}</span></InfoRow>
|
||||
{genderLabel ? <InfoRow icon="fa-venus-mars" label="Gender">{genderLabel}</InfoRow> : null}
|
||||
{countryName ? (
|
||||
<InfoRow icon="fa-earth-americas" label="Country">
|
||||
<span className="flex items-center gap-2">
|
||||
{profile?.country_code ? (
|
||||
<img
|
||||
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
|
||||
alt={countryName}
|
||||
className="h-auto w-4 rounded-sm"
|
||||
onError={(e) => { e.target.style.display = 'none' }}
|
||||
/>
|
||||
) : null}
|
||||
{countryName}
|
||||
</span>
|
||||
</InfoRow>
|
||||
) : null}
|
||||
{website ? (
|
||||
<InfoRow icon="fa-link" label="Website">
|
||||
<a
|
||||
href={website.startsWith('http') ? website : `https://${website}`}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className="text-sky-300 transition-colors hover:text-sky-200 hover:underline"
|
||||
>
|
||||
{(() => {
|
||||
try {
|
||||
const url = website.startsWith('http') ? website : `https://${website}`
|
||||
return new URL(url).hostname
|
||||
} catch { return website }
|
||||
})()}
|
||||
</a>
|
||||
</InfoRow>
|
||||
) : null}
|
||||
{birthDate ? <InfoRow icon="fa-cake-candles" label="Birth date">{birthDate}</InfoRow> : null}
|
||||
{joinDate ? <InfoRow icon="fa-calendar-days" label="Member since">{joinDate}</InfoRow> : null}
|
||||
{lastVisit ? <InfoRow icon="fa-clock" label="Last seen">{lastSeenRelative ? `${lastSeenRelative} · ${lastVisit}` : lastVisit}</InfoRow> : null}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{followers.length > 0 ? (
|
||||
<SectionCard icon="fa-solid fa-user-group" eyebrow="Community" title="Recent followers">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{followers.slice(0, 6).map((follower) => (
|
||||
<a
|
||||
key={follower.id}
|
||||
href={follower.profile_url ?? `/@${follower.username}`}
|
||||
className="group flex items-center gap-3 rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-3 transition-colors hover:border-white/14 hover:bg-white/[0.06]"
|
||||
>
|
||||
<img
|
||||
src={follower.avatar_url ?? '/images/avatar_default.webp'}
|
||||
alt={follower.username}
|
||||
className="h-11 w-11 rounded-2xl object-cover ring-1 ring-white/10 transition-all group-hover:ring-sky-400/30"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-slate-200 group-hover:text-white">{follower.uname || follower.username}</div>
|
||||
<div className="truncate text-xs text-slate-500">@{follower.username}</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{recentAchievements.length > 0 ? (
|
||||
<SectionCard icon="fa-solid fa-trophy" eyebrow="Recent wins" title="Latest achievements">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{recentAchievements.slice(0, 4).map((achievement) => (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className="rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-4 transition-colors hover:bg-white/[0.05]"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-amber-300/15 bg-amber-300/10 text-amber-100">
|
||||
<i className={`fa-solid ${achievement.icon || 'fa-trophy'}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-white">{achievement.name}</div>
|
||||
{achievement.description ? (
|
||||
<div className="mt-1 line-clamp-2 text-sm leading-relaxed text-slate-400">{achievement.description}</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-300/75">
|
||||
{achievement.unlocked_at ? (
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
|
||||
{formatShortDate(achievement.unlocked_at) || 'Unlocked'}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
|
||||
+{formatNumber(achievement.xp_reward ?? 0)} XP
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{stories.length > 0 || comments.length > 0 ? (
|
||||
<SectionCard icon="fa-solid fa-wave-square" eyebrow="Fresh from this creator" title="Recent activity">
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{stories.length > 0 ? (
|
||||
<div className="rounded-[24px] border border-white/8 bg-white/[0.03] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Latest story</div>
|
||||
<span className="rounded-full border border-sky-300/15 bg-sky-400/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-sky-100/80">
|
||||
{formatShortDate(stories[0]?.published_at) || 'Published'}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href={`/stories/${stories[0].slug}`}
|
||||
className="mt-3 block text-lg font-semibold tracking-tight text-white transition-colors hover:text-sky-200"
|
||||
>
|
||||
{stories[0].title}
|
||||
</a>
|
||||
{stories[0].excerpt ? (
|
||||
<p className="mt-2 text-sm leading-7 text-slate-400">
|
||||
{truncateText(stories[0].excerpt, 180)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-300/75">
|
||||
{stories[0].reading_time ? (
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
|
||||
{stories[0].reading_time} min read
|
||||
</span>
|
||||
) : null}
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
|
||||
{formatNumber(stories[0].views ?? 0)} views
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">
|
||||
{formatNumber(stories[0].comments_count ?? 0)} comments
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{comments.length > 0 ? (
|
||||
<div className="rounded-[24px] border border-white/8 bg-white/[0.03] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Latest guestbook comment</div>
|
||||
<span className="rounded-full border border-amber-300/15 bg-amber-300/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-amber-100/80">
|
||||
{formatRelativeDate(comments[0]?.created_at) || 'Recently'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-start gap-3">
|
||||
<img
|
||||
src={comments[0].author_avatar || '/images/avatar_default.webp'}
|
||||
alt={comments[0].author_name}
|
||||
className="h-11 w-11 rounded-2xl object-cover ring-1 ring-white/10"
|
||||
loading="lazy"
|
||||
onError={(e) => { e.target.src = '/images/avatar_default.webp' }}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<a
|
||||
href={comments[0].author_profile_url}
|
||||
className="text-sm font-semibold text-white transition-colors hover:text-sky-200"
|
||||
>
|
||||
{comments[0].author_name}
|
||||
</a>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-400">
|
||||
{truncateText(comments[0].body, 180)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<SectionCard icon="fa-solid fa-sparkles" eyebrow="Creator snapshot" title="Profile snapshot" className="bg-[linear-gradient(180deg,rgba(15,23,42,0.72),rgba(2,6,23,0.5))]">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Creator level</div>
|
||||
<div className="mt-2 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-3xl font-semibold tracking-tight text-white">Lv {formatNumber(user?.level ?? 1)}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">{user?.rank || 'Creator'}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-sky-300/15 bg-sky-400/10 px-3 py-2 text-right">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100/70">XP</div>
|
||||
<div className="mt-1 text-lg font-semibold text-sky-100">{formatNumber(user?.xp ?? 0)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-2 overflow-hidden rounded-full bg-white/8">
|
||||
<div className="h-full rounded-full bg-[linear-gradient(90deg,#38bdf8,#60a5fa,#f59e0b)]" style={{ width: `${Math.max(0, Math.min(100, Number(user?.progress_percent ?? 0)))}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Weekly rank</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-white">{leaderboardRank?.rank ? `#${formatNumber(leaderboardRank.rank)}` : 'Not ranked'}</div>
|
||||
{leaderboardRank?.score ? <div className="mt-1 text-sm text-slate-400">Score {formatNumber(leaderboardRank.score)}</div> : null}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Community size</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-white">{formatNumber(followerCount)}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">Followers</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon="fa-solid fa-chart-simple" eyebrow="Highlights" title="Useful stats">
|
||||
<div className="space-y-3">
|
||||
<InfoRow icon="fa-images" label="Uploads">{formatNumber(stats?.uploads_count ?? 0)}</InfoRow>
|
||||
<InfoRow icon="fa-eye" label="Artwork views received">{formatNumber(stats?.artwork_views_received_count ?? 0)}</InfoRow>
|
||||
<InfoRow icon="fa-download" label="Downloads received">{formatNumber(stats?.downloads_received_count ?? 0)}</InfoRow>
|
||||
<InfoRow icon="fa-heart" label="Favourites received">{formatNumber(stats?.favourites_received_count ?? 0)}</InfoRow>
|
||||
<InfoRow icon="fa-comment" label="Comments received">{formatNumber(stats?.comments_received_count ?? 0)}</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{interestGroups.categories.length > 0 || interestGroups.contentTypes.length > 0 ? (
|
||||
<SectionCard icon="fa-solid fa-layer-group" eyebrow="Creative focus" title="Favourite categories & formats">
|
||||
<div className="space-y-5">
|
||||
{interestGroups.categories.length > 0 ? (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Top categories</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
{interestGroups.categories.map((category) => (
|
||||
<span
|
||||
key={category.label}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-3.5 py-2 text-sm text-slate-200"
|
||||
>
|
||||
<span>{category.label}</span>
|
||||
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[11px] font-semibold text-slate-400">{formatNumber(category.count)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{interestGroups.contentTypes.length > 0 ? (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Preferred formats</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
{interestGroups.contentTypes.map((contentType) => (
|
||||
<span
|
||||
key={contentType.label}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/15 bg-sky-400/10 px-3.5 py-2 text-sm text-sky-100"
|
||||
>
|
||||
<span>{contentType.label}</span>
|
||||
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[11px] font-semibold text-sky-100/70">{formatNumber(contentType.count)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{socialEntries.length > 0 ? (
|
||||
<SectionCard icon="fa-solid fa-share-nodes" eyebrow="Links" title="Social links">
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{socialEntries.map(([platform, link]) => {
|
||||
const si = SOCIAL_ICONS[platform] ?? { icon: 'fa-solid fa-link', label: platform }
|
||||
const href = link.url.startsWith('http') ? link.url : `https://${link.url}`
|
||||
|
||||
return (
|
||||
<a
|
||||
key={platform}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-3.5 py-2.5 text-sm text-slate-300 transition-all hover:border-sky-400/30 hover:bg-white/[0.07] hover:text-white"
|
||||
aria-label={si.label}
|
||||
>
|
||||
<i className={`${si.icon} fa-fw`} />
|
||||
<span>{si.label}</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user