Compare commits
10 Commits
90f244f264
...
f6772f673b
| Author | SHA1 | Date | |
|---|---|---|---|
| f6772f673b | |||
| 5a33ca55a1 | |||
| b9c2d8597d | |||
| dc51d65440 | |||
| 1266f81d35 | |||
| a875203482 | |||
| e3ca845a6d | |||
| 211dc58884 | |||
| 916bb29a53 | |||
| de3ec22ee5 |
46
.env.example
46
.env.example
@@ -232,3 +232,49 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# ─── Early-Stage Growth System ───────────────────────────────────────────────
|
||||
# Set NOVA_EARLY_GROWTH_ENABLED=false to instantly revert to normal behaviour.
|
||||
# NOVA_EARLY_GROWTH_MODE: off | light | aggressive
|
||||
NOVA_EARLY_GROWTH_ENABLED=false
|
||||
NOVA_EARLY_GROWTH_MODE=off
|
||||
|
||||
# Module toggles (only active when NOVA_EARLY_GROWTH_ENABLED=true)
|
||||
NOVA_EGS_ADAPTIVE_WINDOW=true
|
||||
NOVA_EGS_GRID_FILLER=true
|
||||
NOVA_EGS_SPOTLIGHT=true
|
||||
NOVA_EGS_ACTIVITY_LAYER=false
|
||||
|
||||
# AdaptiveTimeWindow thresholds
|
||||
NOVA_EGS_UPLOADS_PER_DAY_NARROW=10
|
||||
NOVA_EGS_UPLOADS_PER_DAY_WIDE=3
|
||||
NOVA_EGS_WINDOW_NARROW_DAYS=7
|
||||
NOVA_EGS_WINDOW_MEDIUM_DAYS=30
|
||||
NOVA_EGS_WINDOW_WIDE_DAYS=90
|
||||
|
||||
# GridFiller minimum items per page
|
||||
NOVA_EGS_GRID_MIN_RESULTS=12
|
||||
|
||||
# Auto-disable when site reaches organic scale
|
||||
NOVA_EGS_AUTO_DISABLE=false
|
||||
NOVA_EGS_AUTO_DISABLE_UPLOADS=50
|
||||
NOVA_EGS_AUTO_DISABLE_USERS=500
|
||||
|
||||
# Cache TTLs (seconds)
|
||||
NOVA_EGS_SPOTLIGHT_TTL=3600
|
||||
NOVA_EGS_BLEND_TTL=300
|
||||
NOVA_EGS_WINDOW_TTL=600
|
||||
NOVA_EGS_ACTIVITY_TTL=1800
|
||||
# ─── OAuth / Social Login ─────────────────────────────────────────────────────
|
||||
# Google — https://console.cloud.google.com/apis/credentials
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=/auth/google/callback
|
||||
|
||||
# Discord — https://discord.com/developers/applications
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
DISCORD_REDIRECT_URI=/auth/discord/callback
|
||||
|
||||
# Apple — https://developer.apple.com/account/resources/identifiers/list/serviceId
|
||||
# Apple sign in removed
|
||||
|
||||
15
README.md
15
README.md
@@ -421,6 +421,21 @@ curl -X POST "$CLIP_BASE_URL$CLIP_ANALYZE_ENDPOINT" \
|
||||
- AI tags appear on the artwork when services are healthy.
|
||||
- Failures are logged, but publish is unaffected.
|
||||
|
||||
## Queue workers
|
||||
|
||||
The contact form mails are queued. To process them you need a worker. Locally you can run a foreground worker:
|
||||
|
||||
```
|
||||
php artisan queue:work --sleep=3 --tries=3
|
||||
```
|
||||
|
||||
For production we provide example configs under `deploy/`:
|
||||
|
||||
- `deploy/supervisor/skinbase-queue.conf` — Supervisor config
|
||||
- `deploy/systemd/skinbase-queue.service` — systemd unit file
|
||||
|
||||
See `docs/QUEUE.md` for full setup steps and commands.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
|
||||
89
app/Console/Commands/AvatarsBulkUpdate.php
Normal file
89
app/Console/Commands/AvatarsBulkUpdate.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class AvatarsBulkUpdate extends Command
|
||||
{
|
||||
protected $signature = 'avatars:bulk-update
|
||||
{path=./user_profiles_avatar.csv : CSV file path (user_id,avatar_hash)}
|
||||
{--dry-run : Do not write to database}
|
||||
';
|
||||
|
||||
protected $description = 'Bulk update user_profiles.avatar_hash from CSV (user_id,avatar_hash)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$path = $this->argument('path');
|
||||
$dry = $this->option('dry-run');
|
||||
|
||||
if (!file_exists($path)) {
|
||||
$this->error("CSV file not found: {$path}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info('Reading CSV: ' . $path);
|
||||
|
||||
if (($handle = fopen($path, 'r')) === false) {
|
||||
$this->error('Unable to open CSV file');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$row = 0;
|
||||
$updates = 0;
|
||||
|
||||
while (($data = fgetcsv($handle)) !== false) {
|
||||
$row++;
|
||||
// Skip empty rows
|
||||
if (count($data) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Expect at least two columns: user_id, avatar_hash
|
||||
$userId = isset($data[0]) ? trim($data[0]) : null;
|
||||
$hash = isset($data[1]) ? trim($data[1]) : null;
|
||||
|
||||
// If first row looks like a header, skip it
|
||||
if ($row === 1 && (!is_numeric($userId) || $userId === 'user_id')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($userId === '' || $hash === '') {
|
||||
$this->line("[skip] row={$row} invalid data");
|
||||
continue;
|
||||
}
|
||||
|
||||
$userId = (int) $userId;
|
||||
|
||||
if ($dry) {
|
||||
$this->line("[dry] user={$userId} would set avatar_hash={$hash}");
|
||||
$updates++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$affected = DB::table('user_profiles')
|
||||
->where('user_id', $userId)
|
||||
->update([ 'avatar_hash' => $hash, 'avatar_updated_at' => now() ]);
|
||||
|
||||
if ($affected) {
|
||||
$this->line("[ok] user={$userId} avatar_hash updated");
|
||||
$updates++;
|
||||
} else {
|
||||
$this->line("[noop] user={$userId} no row updated (missing profile?)");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("[error] user={$userId} {$e->getMessage()}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
$this->info("Done. Processed rows={$row} updates={$updates}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Models\User;
|
||||
use App\Models\UserProfile;
|
||||
use Intervention\Image\ImageManagerStatic as Image;
|
||||
@@ -39,6 +40,7 @@ class AvatarsMigrate extends Command
|
||||
protected $allowed = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
];
|
||||
|
||||
@@ -47,7 +49,7 @@ class AvatarsMigrate extends Command
|
||||
*
|
||||
* @var int[]
|
||||
*/
|
||||
protected $sizes = [32, 64, 128, 256, 512];
|
||||
protected $sizes = [32, 40, 64, 128, 256, 512];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
@@ -56,6 +58,7 @@ class AvatarsMigrate extends Command
|
||||
$removeLegacy = $this->option('remove-legacy');
|
||||
$legacyPath = base_path($this->option('path'));
|
||||
$userId = $this->option('user-id') ? (int) $this->option('user-id') : null;
|
||||
$verbose = $this->output->isVerbose();
|
||||
|
||||
$this->info('Starting avatar migration' . ($dry ? ' (dry-run)' : '') . ($userId ? " for user={$userId}" : ''));
|
||||
|
||||
@@ -72,7 +75,7 @@ class AvatarsMigrate extends Command
|
||||
$query->where('id', $userId);
|
||||
}
|
||||
|
||||
$query->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention) {
|
||||
$query->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention, $verbose) {
|
||||
foreach ($users as $user) {
|
||||
/** @var UserProfile|null $profile */
|
||||
$profile = $user->profile;
|
||||
@@ -87,10 +90,13 @@ class AvatarsMigrate extends Command
|
||||
continue;
|
||||
}
|
||||
|
||||
$source = $this->findLegacyFile($profile, $user->id, $legacyPath);
|
||||
$source = $this->findLegacyFile($profile, $user->id, $legacyPath, 'legacy');
|
||||
|
||||
//dd($source);
|
||||
if (!$source) {
|
||||
if ($verbose) {
|
||||
$this->line("[noop] user={$user->id} no legacy file found");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -123,14 +129,19 @@ class AvatarsMigrate extends Command
|
||||
$contentPart = substr(sha1($originalBlob), 0, 12);
|
||||
$hash = sprintf('%s_%s', $idPart, $contentPart);
|
||||
|
||||
if ($dry) {
|
||||
$this->line("[dry] user={$user->id} would write avatars for hash={$hash}");
|
||||
} else {
|
||||
// Use hash-based directory structure: avatars/ab/cd/{hash}/
|
||||
// Precompute storage dir for dry-run and real run
|
||||
$hashPrefix1 = substr($hash, 0, 2);
|
||||
$hashPrefix2 = substr($hash, 2, 2);
|
||||
$dir = "avatars/{$hashPrefix1}/{$hashPrefix2}/{$hash}";
|
||||
Storage::disk('public')->makeDirectory($dir);
|
||||
|
||||
// CDN base for public URLs
|
||||
$cdnBase = rtrim((string) config('cdn.avatar_url', 'https://files.skinbase.org'), '/');
|
||||
|
||||
if ($dry) {
|
||||
$absPathDry = Storage::disk('public')->path("{$dir}/original.webp");
|
||||
$publicUrlDry = sprintf('%s/%s/original.webp?v=%s', $cdnBase, $dir, $hash);
|
||||
$this->line("[dry] user={$user->id} would write avatars for hash={$hash} path={$absPathDry} url={$publicUrlDry}");
|
||||
} else {
|
||||
|
||||
// Save original.webp
|
||||
Storage::disk('public')->put("{$dir}/original.webp", $originalBlob);
|
||||
@@ -155,7 +166,9 @@ class AvatarsMigrate extends Command
|
||||
$profile->avatar_updated_at = Carbon::now();
|
||||
$profile->save();
|
||||
|
||||
$this->line("[ok] user={$user->id} migrated hash={$hash}");
|
||||
$absPath = Storage::disk('public')->path("{$dir}/original.webp");
|
||||
$publicUrl = sprintf('%s/%s/original.webp?v=%s', $cdnBase, $dir, $hash);
|
||||
$this->line("[ok] user={$user->id} migrated hash={$hash} path={$absPath} url={$publicUrl}");
|
||||
|
||||
if ($removeLegacy && !empty($profile->avatar_legacy)) {
|
||||
$legacyFile = base_path("public/files/usericons/{$profile->avatar_legacy}");
|
||||
@@ -185,8 +198,19 @@ class AvatarsMigrate extends Command
|
||||
* @param string $legacyBase
|
||||
* @return string|null
|
||||
*/
|
||||
protected function findLegacyFile(UserProfile $profile, int $userId, string $legacyBase): ?string
|
||||
protected function findLegacyFile(UserProfile $profile, int $userId, string $legacyBase, ?string $legacyConnection = null): ?string
|
||||
{
|
||||
|
||||
$avatar = DB::connection('legacy')->table('users')->where('user_id', $userId)->value('icon');
|
||||
|
||||
if (!empty($profile->avatar_legacy)) {
|
||||
$p = $legacyBase . DIRECTORY_SEPARATOR . $avatar;
|
||||
if (file_exists($p)) {
|
||||
return $p;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 1) If profile->avatar_legacy looks like a filename, try it
|
||||
if (!empty($profile->avatar_legacy)) {
|
||||
$p = $legacyBase . DIRECTORY_SEPARATOR . $profile->avatar_legacy;
|
||||
@@ -212,6 +236,34 @@ class AvatarsMigrate extends Command
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Fallback: try legacy database connection (connection name 'legacy')
|
||||
// If a legacy DB connection is configured, query `users.icon` for avatar filename.
|
||||
try {
|
||||
$conn = $legacyConnection ?: (config('database.connections.legacy') ? 'legacy' : null);
|
||||
if ($conn) {
|
||||
$icon = DB::connection($conn)->table('users')->where('id', $userId)->value('icon');
|
||||
if (!empty($icon)) {
|
||||
// If icon looks like an absolute path, use it directly; otherwise resolve under legacy base path
|
||||
$p = $icon;
|
||||
if (!file_exists($p)) {
|
||||
$p = $legacyBase . DIRECTORY_SEPARATOR . ltrim($icon, '\/');
|
||||
}
|
||||
|
||||
if (file_exists($p)) {
|
||||
if ($this->output->isVerbose()) {
|
||||
$this->line("[legacy-db] user={$userId} icon={$icon} resolved={$p}");
|
||||
}
|
||||
return $p;
|
||||
}
|
||||
if ($this->output->isVerbose()) {
|
||||
$this->line("[legacy-db] user={$userId} icon={$icon} not found at resolved path {$p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Non-fatal: just skip legacy DB if query fails or connection missing
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -308,6 +360,53 @@ class AvatarsMigrate extends Command
|
||||
return imagecreatefromwebp($path);
|
||||
}
|
||||
return false;
|
||||
case 'image/gif':
|
||||
if (function_exists('imagecreatefromgif')) {
|
||||
$res = imagecreatefromgif($path);
|
||||
if (!$res) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure returned resource is truecolor (WebP requires truecolor)
|
||||
if (!imageistruecolor($res)) {
|
||||
$w = imagesx($res);
|
||||
$h = imagesy($res);
|
||||
$true = imagecreatetruecolor($w, $h);
|
||||
|
||||
// Preserve transparency where possible
|
||||
imagealphablending($true, false);
|
||||
imagesavealpha($true, true);
|
||||
|
||||
// Fill with fully transparent color
|
||||
$transparent = imagecolorallocatealpha($true, 0, 0, 0, 127);
|
||||
imagefilledrectangle($true, 0, 0, $w, $h, $transparent);
|
||||
|
||||
// If the source has an indexed transparent color, try to preserve it
|
||||
$transIndex = imagecolortransparent($res);
|
||||
if ($transIndex >= 0) {
|
||||
try {
|
||||
$colorTotal = imagecolorstotal($res);
|
||||
if ($transIndex >= 0 && $transIndex < $colorTotal) {
|
||||
$colors = imagecolorsforindex($res, $transIndex);
|
||||
if (is_array($colors)) {
|
||||
$alphaColor = imagecolorallocatealpha($true, $colors['red'], $colors['green'], $colors['blue'], 127);
|
||||
imagefilledrectangle($true, 0, 0, $w, $h, $alphaColor);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Non-fatal: skip preserving indexed transparent color
|
||||
}
|
||||
}
|
||||
|
||||
// Copy pixels
|
||||
imagecopy($true, $res, 0, 0, 0, 0, $w, $h);
|
||||
imagedestroy($res);
|
||||
return $true;
|
||||
}
|
||||
|
||||
return $res;
|
||||
}
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
113
app/Console/Commands/MetricsSnapshotHourlyCommand.php
Normal file
113
app/Console/Commands/MetricsSnapshotHourlyCommand.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Collect hourly metric snapshots for artworks.
|
||||
*
|
||||
* Runs on cron every hour. Inserts a row per artwork into
|
||||
* artwork_metric_snapshots_hourly with the current totals.
|
||||
* Deltas are computed by the heat recalculation command.
|
||||
*
|
||||
* Usage: php artisan nova:metrics-snapshot-hourly
|
||||
* php artisan nova:metrics-snapshot-hourly --days=30 --chunk=500 --dry-run
|
||||
*/
|
||||
class MetricsSnapshotHourlyCommand extends Command
|
||||
{
|
||||
protected $signature = 'nova:metrics-snapshot-hourly
|
||||
{--days=60 : Only snapshot artworks created within this many days}
|
||||
{--chunk=1000 : Chunk size for DB queries}
|
||||
{--dry-run : Log what would be written without persisting}';
|
||||
|
||||
protected $description = 'Collect hourly metric snapshots for rising/heat calculation';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$bucketHour = now()->startOfHour();
|
||||
|
||||
$this->info("[nova:metrics-snapshot-hourly] bucket={$bucketHour->toDateTimeString()} days={$days} chunk={$chunk}" . ($dryRun ? ' (dry-run)' : ''));
|
||||
|
||||
$snapshotCount = 0;
|
||||
$skipCount = 0;
|
||||
|
||||
// Query artworks eligible for snapshotting:
|
||||
// - created within $days OR has a ranking_score above 0
|
||||
// First collect eligible IDs, then process in chunks
|
||||
$eligibleIds = DB::table('artworks')
|
||||
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id')
|
||||
->where(function ($q) use ($days) {
|
||||
$q->where('artworks.created_at', '>=', now()->subDays($days))
|
||||
->orWhere(function ($q2) {
|
||||
$q2->whereNotNull('s.ranking_score')
|
||||
->where('s.ranking_score', '>', 0);
|
||||
});
|
||||
})
|
||||
->whereNull('artworks.deleted_at')
|
||||
->where('artworks.is_approved', true)
|
||||
->pluck('artworks.id');
|
||||
|
||||
if ($eligibleIds->isEmpty()) {
|
||||
$this->info('No eligible artworks found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($eligibleIds->chunk($chunk) as $chunkIds) {
|
||||
$artworkIds = $chunkIds->values()->all();
|
||||
|
||||
$stats = DB::table('artwork_stats')
|
||||
->whereIn('artwork_id', $artworkIds)
|
||||
->get()
|
||||
->keyBy('artwork_id');
|
||||
|
||||
$rows = [];
|
||||
foreach ($artworkIds as $artworkId) {
|
||||
$stat = $stats->get($artworkId);
|
||||
|
||||
$rows[] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'bucket_hour' => $bucketHour,
|
||||
'views_count' => (int) ($stat?->views ?? 0),
|
||||
'downloads_count' => (int) ($stat?->downloads ?? 0),
|
||||
'favourites_count' => (int) ($stat?->favorites ?? 0),
|
||||
'comments_count' => (int) ($stat?->comments_count ?? 0),
|
||||
'shares_count' => (int) ($stat?->shares_count ?? 0),
|
||||
'created_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$snapshotCount += count($rows);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!empty($rows)) {
|
||||
// Upsert: if (artwork_id, bucket_hour) already exists, update totals
|
||||
DB::table('artwork_metric_snapshots_hourly')->upsert(
|
||||
$rows,
|
||||
['artwork_id', 'bucket_hour'],
|
||||
['views_count', 'downloads_count', 'favourites_count', 'comments_count', 'shares_count']
|
||||
);
|
||||
|
||||
$snapshotCount += count($rows);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Snapshots written: {$snapshotCount} | Skipped: {$skipCount}");
|
||||
|
||||
Log::info('[nova:metrics-snapshot-hourly] completed', [
|
||||
'bucket' => $bucketHour->toDateTimeString(),
|
||||
'written' => $snapshotCount,
|
||||
'skipped' => $skipCount,
|
||||
'dry_run' => $dryRun,
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
197
app/Console/Commands/MigrateStoriesCommand.php
Normal file
197
app/Console/Commands/MigrateStoriesCommand.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryAuthor;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Migrate legacy interview records into the new Stories system.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan stories:migrate-legacy
|
||||
* php artisan stories:migrate-legacy --dry-run
|
||||
* php artisan stories:migrate-legacy --legacy-connection=legacy --chunk=100
|
||||
*
|
||||
* Idempotent: running multiple times will not duplicate records.
|
||||
* Legacy records are identified via `legacy_interview_id` column on stories table.
|
||||
*/
|
||||
final class MigrateStoriesCommand extends Command
|
||||
{
|
||||
protected $signature = 'stories:migrate-legacy
|
||||
{--chunk=50 : number of records to process per batch}
|
||||
{--dry-run : preview migration without persisting changes}
|
||||
{--legacy-connection= : DB connection name for legacy database (default: uses default connection)}
|
||||
{--legacy-table=interviews : legacy interviews table name}
|
||||
';
|
||||
|
||||
protected $description = 'Migrate legacy interview records into the new nova Stories system (idempotent)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$legacyConn = $this->option('legacy-connection') ?: null;
|
||||
$table = (string) $this->option('legacy-table');
|
||||
|
||||
$this->info('Nova Stories — legacy interview migration');
|
||||
$this->info("Table: {$table} | Chunk: {$chunk} | Dry-run: " . ($dryRun ? 'YES' : 'NO'));
|
||||
$this->newLine();
|
||||
|
||||
try {
|
||||
$db = $legacyConn ? DB::connection($legacyConn) : DB::connection();
|
||||
// Quick existence check
|
||||
$db->table($table)->limit(1)->get();
|
||||
} catch (Throwable $e) {
|
||||
$this->error("Cannot access table `{$table}`: " . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$inserted = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
$db->table($table)->orderBy('id')->chunkById($chunk, function ($rows) use (
|
||||
$dryRun, &$inserted, &$skipped, &$failed
|
||||
) {
|
||||
foreach ($rows as $row) {
|
||||
$legacyId = (int) ($row->id ?? 0);
|
||||
|
||||
if (! $legacyId) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Idempotency: skip if already migrated
|
||||
if (Story::where('legacy_interview_id', $legacyId)->exists()) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// ── Resolve / create author ──────────────────────────────
|
||||
$authorName = $this->coerceString($row->username ?? $row->author ?? $row->uname ?? '');
|
||||
$authorAvatar = $this->coerceString($row->icon ?? $row->avatar ?? '');
|
||||
|
||||
$author = null;
|
||||
if ($authorName) {
|
||||
$author = StoryAuthor::firstOrCreate(
|
||||
['name' => $authorName],
|
||||
['avatar' => $authorAvatar ?: null]
|
||||
);
|
||||
}
|
||||
|
||||
// ── Build slug ───────────────────────────────────────────
|
||||
$rawTitle = $this->coerceString(
|
||||
$row->headline ?? $row->title ?? $row->subject ?? ''
|
||||
) ?: 'interview-' . $legacyId;
|
||||
|
||||
$slugBase = Str::slug(Str::limit($rawTitle, 180));
|
||||
$slug = $slugBase ?: 'interview-' . $legacyId;
|
||||
|
||||
// Ensure uniqueness
|
||||
$slug = $this->uniqueSlug($slug);
|
||||
|
||||
// ── Excerpt ──────────────────────────────────────────────
|
||||
$fullContent = $this->coerceString(
|
||||
$row->content ?? $row->tekst ?? $row->body ?? $row->text ?? ''
|
||||
);
|
||||
|
||||
$excerpt = $this->coerceString($row->excerpt ?? $row->intro ?? $row->lead ?? '');
|
||||
if (! $excerpt && $fullContent) {
|
||||
$excerpt = Str::limit(strip_tags($fullContent), 200);
|
||||
}
|
||||
|
||||
// ── Cover image ──────────────────────────────────────────
|
||||
$coverRaw = $this->coerceString($row->pic ?? $row->image ?? $row->cover ?? $row->photo ?? '');
|
||||
$coverImage = $coverRaw ? 'legacy/interviews/' . ltrim($coverRaw, '/') : null;
|
||||
|
||||
// ── Published date ───────────────────────────────────────
|
||||
$publishedAt = null;
|
||||
foreach (['datum', 'published_at', 'date', 'created_at'] as $field) {
|
||||
$val = $row->{$field} ?? null;
|
||||
if ($val) {
|
||||
$ts = strtotime((string) $val);
|
||||
if ($ts) {
|
||||
$publishedAt = date('Y-m-d H:i:s', $ts);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [DRY-RUN] Would import: #{$legacyId} → {$slug}");
|
||||
$inserted++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Story::create([
|
||||
'slug' => $slug,
|
||||
'title' => Str::limit($rawTitle, 255),
|
||||
'excerpt' => $excerpt ?: null,
|
||||
'content' => $fullContent ?: null,
|
||||
'cover_image' => $coverImage,
|
||||
'author_id' => $author?->id,
|
||||
'views' => max(0, (int) ($row->views ?? $row->hits ?? 0)),
|
||||
'featured' => false,
|
||||
'status' => 'published',
|
||||
'published_at' => $publishedAt,
|
||||
'legacy_interview_id' => $legacyId,
|
||||
]);
|
||||
|
||||
$this->line(" Imported: #{$legacyId} → {$slug}");
|
||||
$inserted++;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn(" FAILED #{$legacyId}: " . $e->getMessage());
|
||||
Log::warning("stories:migrate-legacy failed for id={$legacyId}", ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Migration complete.");
|
||||
$this->table(
|
||||
['Inserted', 'Skipped (existing)', 'Failed'],
|
||||
[[$inserted, $skipped, $failed]]
|
||||
);
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function coerceString(mixed $value, string $default = ''): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
$str = trim((string) $value);
|
||||
return $str !== '' ? $str : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the slug is unique, appending a numeric suffix if needed.
|
||||
*/
|
||||
private function uniqueSlug(string $slug): string
|
||||
{
|
||||
if (! Story::where('slug', $slug)->exists()) {
|
||||
return $slug;
|
||||
}
|
||||
|
||||
$i = 2;
|
||||
do {
|
||||
$candidate = $slug . '-' . $i++;
|
||||
} while (Story::where('slug', $candidate)->exists());
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
40
app/Console/Commands/PruneMetricSnapshotsCommand.php
Normal file
40
app/Console/Commands/PruneMetricSnapshotsCommand.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Prune old hourly metric snapshots to prevent unbounded table growth.
|
||||
*
|
||||
* Usage: php artisan nova:prune-metric-snapshots
|
||||
* php artisan nova:prune-metric-snapshots --keep-days=7
|
||||
*/
|
||||
class PruneMetricSnapshotsCommand extends Command
|
||||
{
|
||||
protected $signature = 'nova:prune-metric-snapshots
|
||||
{--keep-days=7 : Keep snapshots for this many days}';
|
||||
|
||||
protected $description = 'Delete old hourly metric snapshots beyond the retention window';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$keepDays = (int) $this->option('keep-days');
|
||||
$cutoff = now()->subDays($keepDays);
|
||||
|
||||
$deleted = DB::table('artwork_metric_snapshots_hourly')
|
||||
->where('bucket_hour', '<', $cutoff)
|
||||
->delete();
|
||||
|
||||
$this->info("Pruned {$deleted} snapshot rows older than {$keepDays} days.");
|
||||
|
||||
Log::info('[nova:prune-metric-snapshots] completed', [
|
||||
'deleted' => $deleted,
|
||||
'keep_days' => $keepDays,
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
122
app/Console/Commands/PublishScheduledArtworksCommand.php
Normal file
122
app/Console/Commands/PublishScheduledArtworksCommand.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ActivityEvent;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* PublishScheduledArtworksCommand
|
||||
*
|
||||
* Runs every minute (via Kernel schedule).
|
||||
* Finds artworks with:
|
||||
* - artwork_status = 'scheduled'
|
||||
* - publish_at <= now() (UTC)
|
||||
* - is_approved = true (respect moderation gate)
|
||||
*
|
||||
* Publishes each one:
|
||||
* - sets is_public = true
|
||||
* - sets published_at = now()
|
||||
* - sets artwork_status = 'published'
|
||||
* - dispatches Meilisearch reindex (via Scout)
|
||||
* - records activity event
|
||||
*
|
||||
* Safe to run concurrently (DB row lock prevents double-publish).
|
||||
*/
|
||||
class PublishScheduledArtworksCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:publish-scheduled
|
||||
{--dry-run : List candidate artworks without publishing}
|
||||
{--limit=100 : Max artworks to process per run}';
|
||||
|
||||
protected $description = 'Publish scheduled artworks whose publish_at datetime has passed.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = (int) $this->option('limit');
|
||||
|
||||
$now = now()->utc();
|
||||
|
||||
$candidates = Artwork::query()
|
||||
->where('artwork_status', 'scheduled')
|
||||
->where('publish_at', '<=', $now)
|
||||
->where('is_approved', true)
|
||||
->orderBy('publish_at')
|
||||
->limit($limit)
|
||||
->get(['id', 'user_id', 'title', 'publish_at', 'artwork_status']);
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
$this->line('No scheduled artworks due for publishing.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$candidates->count()} artwork(s) to publish." . ($dryRun ? ' [DRY RUN]' : ''));
|
||||
|
||||
$published = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would publish artwork #{$candidate->id}: \"{$candidate->title}\"");
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($candidate, $now, &$published) {
|
||||
// Re-fetch with lock to avoid double-publish in concurrent runs
|
||||
$artwork = Artwork::query()
|
||||
->lockForUpdate()
|
||||
->where('id', $candidate->id)
|
||||
->where('artwork_status', 'scheduled')
|
||||
->first();
|
||||
|
||||
if (! $artwork) {
|
||||
// Already published or status changed – skip
|
||||
return;
|
||||
}
|
||||
|
||||
$artwork->is_public = true;
|
||||
$artwork->published_at = $now;
|
||||
$artwork->artwork_status = 'published';
|
||||
$artwork->save();
|
||||
|
||||
// Trigger Meilisearch reindex via Scout (if searchable trait present)
|
||||
if (method_exists($artwork, 'searchable')) {
|
||||
try {
|
||||
$artwork->searchable();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("PublishScheduled: scout reindex failed for #{$artwork->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
// Record activity event
|
||||
try {
|
||||
ActivityEvent::record(
|
||||
actorId: (int) $artwork->user_id,
|
||||
type: ActivityEvent::TYPE_UPLOAD,
|
||||
targetType: ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: (int) $artwork->id,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
$published++;
|
||||
$this->line(" Published artwork #{$artwork->id}: \"{$artwork->title}\"");
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
$errors++;
|
||||
Log::error("PublishScheduledArtworksCommand: failed to publish artwork #{$candidate->id}: {$e->getMessage()}");
|
||||
$this->error(" Failed to publish #{$candidate->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->info("Done. Published: {$published}, Errors: {$errors}.");
|
||||
}
|
||||
|
||||
return $errors > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
46
app/Console/Commands/PublishScheduledPostsCommand.php
Normal file
46
app/Console/Commands/PublishScheduledPostsCommand.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Publishes posts whose publish_at timestamp has passed.
|
||||
* Scheduled every minute via console/kernel.
|
||||
*/
|
||||
class PublishScheduledPostsCommand extends Command
|
||||
{
|
||||
protected $signature = 'posts:publish-scheduled';
|
||||
protected $description = 'Publish all scheduled posts whose publish_at time has been reached.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$count = Post::where('status', Post::STATUS_SCHEDULED)
|
||||
->where('publish_at', '<=', now())
|
||||
->count();
|
||||
|
||||
if ($count === 0) {
|
||||
$this->line('No scheduled posts to publish.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$published = 0;
|
||||
|
||||
Post::where('status', Post::STATUS_SCHEDULED)
|
||||
->where('publish_at', '<=', now())
|
||||
->chunkById(100, function ($posts) use (&$published) {
|
||||
foreach ($posts as $post) {
|
||||
DB::transaction(function () use ($post) {
|
||||
$post->update(['status' => Post::STATUS_PUBLISHED]);
|
||||
});
|
||||
$published++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Published {$published} scheduled post(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
166
app/Console/Commands/RecalculateHeatCommand.php
Normal file
166
app/Console/Commands/RecalculateHeatCommand.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Recalculate heat_score for artworks based on hourly metric snapshots.
|
||||
*
|
||||
* Runs every 10–15 minutes via scheduler.
|
||||
*
|
||||
* Formula:
|
||||
* raw_heat = views_delta*1 + downloads_delta*3 + favourites_delta*6
|
||||
* + comments_delta*8 + shares_delta*12
|
||||
*
|
||||
* age_factor = 1 / (1 + hours_since_upload / 24)
|
||||
*
|
||||
* heat_score = raw_heat * age_factor
|
||||
*
|
||||
* Usage: php artisan nova:recalculate-heat
|
||||
* php artisan nova:recalculate-heat --days=60 --chunk=1000 --dry-run
|
||||
*/
|
||||
class RecalculateHeatCommand extends Command
|
||||
{
|
||||
protected $signature = 'nova:recalculate-heat
|
||||
{--days=60 : Only process artworks created within this many days}
|
||||
{--chunk=1000 : Chunk size for DB queries}
|
||||
{--dry-run : Compute scores without writing to DB}';
|
||||
|
||||
protected $description = 'Recalculate heat/momentum scores for the Rising engine';
|
||||
|
||||
/** Delta weights per the spec */
|
||||
private const WEIGHTS = [
|
||||
'views' => 1,
|
||||
'downloads' => 3,
|
||||
'favourites' => 6,
|
||||
'comments' => 8,
|
||||
'shares' => 12,
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$now = now();
|
||||
$currentHour = $now->copy()->startOfHour();
|
||||
$prevHour = $currentHour->copy()->subHour();
|
||||
|
||||
$this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} days={$days}" . ($dryRun ? ' (dry-run)' : ''));
|
||||
|
||||
$updatedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
// Process in chunks using artwork IDs that have at least one snapshot in the two hours
|
||||
$artworkIds = DB::table('artwork_metric_snapshots_hourly')
|
||||
->whereIn('bucket_hour', [$currentHour, $prevHour])
|
||||
->distinct()
|
||||
->pluck('artwork_id');
|
||||
|
||||
if ($artworkIds->isEmpty()) {
|
||||
$this->warn('No snapshots found for the current or previous hour. Run nova:metrics-snapshot-hourly first.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Load all snapshots for the two hours in bulk
|
||||
$snapshots = DB::table('artwork_metric_snapshots_hourly')
|
||||
->whereIn('bucket_hour', [$currentHour, $prevHour])
|
||||
->whereIn('artwork_id', $artworkIds)
|
||||
->get()
|
||||
->groupBy('artwork_id');
|
||||
|
||||
// Load artwork published_at dates for age factor (use published_at, fall back to created_at)
|
||||
$artworkDates = DB::table('artworks')
|
||||
->whereIn('id', $artworkIds)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_approved', true)
|
||||
->select('id', 'published_at', 'created_at')
|
||||
->get()
|
||||
->mapWithKeys(fn ($row) => [
|
||||
$row->id => \Carbon\Carbon::parse($row->published_at ?? $row->created_at),
|
||||
]);
|
||||
|
||||
// Process in chunks
|
||||
foreach ($artworkIds->chunk($chunk) as $chunkIds) {
|
||||
$upsertRows = [];
|
||||
|
||||
foreach ($chunkIds as $artworkId) {
|
||||
$createdAt = $artworkDates->get($artworkId);
|
||||
if (!$createdAt) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$artworkSnapshots = $snapshots->get($artworkId);
|
||||
if (!$artworkSnapshots || $artworkSnapshots->isEmpty()) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $currentHour->toDateTimeString());
|
||||
$prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString());
|
||||
|
||||
// If we only have one snapshot, use it as current with zero deltas
|
||||
if (!$currentSnapshot && !$prevSnapshot) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate deltas
|
||||
$viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0));
|
||||
$downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0));
|
||||
$favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0));
|
||||
$commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0));
|
||||
$sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0));
|
||||
|
||||
// Raw heat
|
||||
$rawHeat = ($viewsDelta * self::WEIGHTS['views'])
|
||||
+ ($downloadsDelta * self::WEIGHTS['downloads'])
|
||||
+ ($favouritesDelta * self::WEIGHTS['favourites'])
|
||||
+ ($commentsDelta * self::WEIGHTS['comments'])
|
||||
+ ($sharesDelta * self::WEIGHTS['shares']);
|
||||
|
||||
// Age factor: favors newer works
|
||||
$hoursSinceUpload = abs($now->floatDiffInHours($createdAt));
|
||||
$ageFactor = 1.0 / (1.0 + ($hoursSinceUpload / 24.0));
|
||||
|
||||
// Final heat score
|
||||
$heatScore = max(0, $rawHeat * $ageFactor);
|
||||
|
||||
$upsertRows[] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'heat_score' => round($heatScore, 4),
|
||||
'heat_score_updated_at' => $now,
|
||||
'views_1h' => $viewsDelta,
|
||||
'downloads_1h' => $downloadsDelta,
|
||||
'favourites_1h' => $favouritesDelta,
|
||||
'comments_1h' => $commentsDelta,
|
||||
'shares_1h' => $sharesDelta,
|
||||
];
|
||||
|
||||
$updatedCount++;
|
||||
}
|
||||
|
||||
if (!$dryRun && !empty($upsertRows)) {
|
||||
DB::table('artwork_stats')->upsert(
|
||||
$upsertRows,
|
||||
['artwork_id'],
|
||||
['heat_score', 'heat_score_updated_at', 'views_1h', 'downloads_1h', 'favourites_1h', 'comments_1h', 'shares_1h']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Heat scores updated: {$updatedCount} | Skipped: {$skippedCount}");
|
||||
|
||||
Log::info('[nova:recalculate-heat] completed', [
|
||||
'updated' => $updatedCount,
|
||||
'skipped' => $skippedCount,
|
||||
'dry_run' => $dryRun,
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
81
app/Console/Commands/RecalculateRankingsCommand.php
Normal file
81
app/Console/Commands/RecalculateRankingsCommand.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Ranking\ArtworkRankingService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* php artisan nova:recalculate-rankings [--chunk=500] [--sync-rank-scores] [--skip-index]
|
||||
*
|
||||
* Ranking Engine V2 — recalculates ranking_score and engagement_velocity
|
||||
* for all public, approved artworks. Designed to run every 30 minutes.
|
||||
*/
|
||||
class RecalculateRankingsCommand extends Command
|
||||
{
|
||||
protected $signature = 'nova:recalculate-rankings
|
||||
{--chunk=500 : DB chunk size for batch processing}
|
||||
{--sync-rank-scores : Also update rank_artwork_scores table with V2 formula}
|
||||
{--skip-index : Skip dispatching Meilisearch re-index jobs}';
|
||||
|
||||
protected $description = 'Recalculate V2 ranking scores (engagement + shares + decay + authority + velocity)';
|
||||
|
||||
public function __construct(private readonly ArtworkRankingService $ranking)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunkSize = (int) $this->option('chunk');
|
||||
$syncRankScores = (bool) $this->option('sync-rank-scores');
|
||||
$skipIndex = (bool) $this->option('skip-index');
|
||||
|
||||
// ── Step 1: Recalculate ranking_score + engagement_velocity ─────
|
||||
$this->info('Ranking V2: recalculating scores …');
|
||||
$start = microtime(true);
|
||||
$updated = $this->ranking->recalculateAll($chunkSize);
|
||||
$elapsed = round(microtime(true) - $start, 2);
|
||||
$this->info(" ✓ {$updated} artworks scored in {$elapsed}s");
|
||||
|
||||
// ── Step 2 (optional): Sync to rank_artwork_scores ─────────────
|
||||
if ($syncRankScores) {
|
||||
$this->info('Syncing to rank_artwork_scores …');
|
||||
$start2 = microtime(true);
|
||||
$synced = $this->ranking->syncToRankScores($chunkSize);
|
||||
$elapsed2 = round(microtime(true) - $start2, 2);
|
||||
$this->info(" ✓ {$synced} rank scores synced in {$elapsed2}s");
|
||||
}
|
||||
|
||||
// ── Step 3 (optional): Trigger Meilisearch re-index ────────────
|
||||
if (! $skipIndex) {
|
||||
$this->info('Dispatching Meilisearch index jobs …');
|
||||
$this->dispatchIndexJobs();
|
||||
$this->info(' ✓ Index jobs dispatched');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch IndexArtworkJob for artworks updated in the last 24 hours
|
||||
* (or recently scored). Keeps the search index current.
|
||||
*/
|
||||
private function dispatchIndexJobs(): void
|
||||
{
|
||||
\App\Models\Artwork::query()
|
||||
->select('id')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '>=', now()->subDays(30)->toDateTimeString())
|
||||
->chunkById(500, function ($artworks): void {
|
||||
foreach ($artworks as $artwork) {
|
||||
\App\Jobs\IndexArtworkJob::dispatch($artwork->id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ReindexRecentPublishedArtworksCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:search-reindex-recent
|
||||
{--hours=72 : Reindex artworks published in the last N hours}
|
||||
{--limit=1000 : Maximum artworks to process in this run}
|
||||
{--id=* : Specific artwork IDs to reindex (overrides --hours window)}
|
||||
{--dry-run : Show candidates without dispatching index jobs}';
|
||||
|
||||
protected $description = 'Reindex recently published public artworks to recover missed search indexing.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$hours = max(1, (int) $this->option('hours'));
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', (array) $this->option('id')), static fn (int $id): bool => $id > 0)));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$since = now()->subHours($hours);
|
||||
|
||||
$query = Artwork::query()
|
||||
->whereNull('deleted_at')
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at');
|
||||
|
||||
if ($ids !== []) {
|
||||
$query->whereIn('id', $ids)->orderBy('id');
|
||||
} else {
|
||||
$query->where('published_at', '>=', $since)
|
||||
->orderByDesc('published_at');
|
||||
}
|
||||
|
||||
$candidates = $query->limit($limit)->get(['id', 'title', 'slug', 'published_at']);
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
if ($ids !== []) {
|
||||
$this->line('No matching published artworks found for the provided --id values.');
|
||||
} else {
|
||||
$this->line("No published artworks found in the last {$hours} hour(s).");
|
||||
}
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($ids !== []) {
|
||||
$this->info('Found ' . $candidates->count() . ' target artwork(s) by --id.' . ($dryRun ? ' [DRY RUN]' : ''));
|
||||
} else {
|
||||
$this->info("Found {$candidates->count()} artwork(s) published in the last {$hours} hour(s)." . ($dryRun ? ' [DRY RUN]' : ''));
|
||||
}
|
||||
|
||||
foreach ($candidates as $artwork) {
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would reindex #{$artwork->id} ({$artwork->slug})");
|
||||
continue;
|
||||
}
|
||||
|
||||
IndexArtworkJob::dispatchSync((int) $artwork->id);
|
||||
$this->line(" Reindexed #{$artwork->id} ({$artwork->slug})");
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->info('Done. Recent published artworks were reindexed.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
28
app/Console/Commands/WarmPostTrendingCommand.php
Normal file
28
app/Console/Commands/WarmPostTrendingCommand.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Posts\PostTrendingService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Warms the post trending cache so requests are fast.
|
||||
* Scheduled every 2 minutes to match the cache TTL.
|
||||
*/
|
||||
class WarmPostTrendingCommand extends Command
|
||||
{
|
||||
protected $signature = 'posts:warm-trending';
|
||||
protected $description = 'Refresh the post trending feed cache.';
|
||||
|
||||
public function __construct(private PostTrendingService $trending)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$ids = $this->trending->refresh();
|
||||
$this->info('Trending feed cache refreshed. ' . count($ids) . ' post(s) ranked.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,13 @@ use App\Console\Commands\EvaluateFeedWeightsCommand;
|
||||
use App\Console\Commands\AiTagArtworksCommand;
|
||||
use App\Console\Commands\CompareFeedAbCommand;
|
||||
use App\Console\Commands\RecalculateTrendingCommand;
|
||||
use App\Console\Commands\RecalculateRankingsCommand;
|
||||
use App\Console\Commands\MetricsSnapshotHourlyCommand;
|
||||
use App\Console\Commands\RecalculateHeatCommand;
|
||||
use App\Jobs\RankComputeArtworkScoresJob;
|
||||
use App\Jobs\RankBuildListsJob;
|
||||
use App\Uploads\Commands\CleanupUploadsCommand;
|
||||
use App\Console\Commands\PublishScheduledArtworksCommand;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
{
|
||||
@@ -30,8 +34,10 @@ class Kernel extends ConsoleKernel
|
||||
ImportCategories::class,
|
||||
MigrateFeaturedWorks::class,
|
||||
\App\Console\Commands\AvatarsMigrate::class,
|
||||
\App\Console\Commands\AvatarsBulkUpdate::class,
|
||||
\App\Console\Commands\ResetAllUserPasswords::class,
|
||||
CleanupUploadsCommand::class,
|
||||
PublishScheduledArtworksCommand::class,
|
||||
BackfillArtworkEmbeddingsCommand::class,
|
||||
AggregateSimilarArtworkAnalyticsCommand::class,
|
||||
AggregateFeedAnalyticsCommand::class,
|
||||
@@ -40,6 +46,9 @@ class Kernel extends ConsoleKernel
|
||||
AiTagArtworksCommand::class,
|
||||
\App\Console\Commands\MigrateFollows::class,
|
||||
RecalculateTrendingCommand::class,
|
||||
RecalculateRankingsCommand::class,
|
||||
MetricsSnapshotHourlyCommand::class,
|
||||
RecalculateHeatCommand::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -48,6 +57,13 @@ class Kernel extends ConsoleKernel
|
||||
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
|
||||
{
|
||||
$schedule->command('uploads:cleanup')->dailyAt('03:00');
|
||||
|
||||
// Publish artworks whose scheduled publish_at has passed
|
||||
$schedule->command('artworks:publish-scheduled')
|
||||
->everyMinute()
|
||||
->name('publish-scheduled-artworks')
|
||||
->withoutOverlapping(2) // prevent overlap up to 2 minutes
|
||||
->runInBackground();
|
||||
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
|
||||
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20');
|
||||
// Recalculate trending scores every 30 minutes (staggered to reduce peak load)
|
||||
@@ -59,6 +75,30 @@ class Kernel extends ConsoleKernel
|
||||
$schedule->job(new RankComputeArtworkScoresJob)->hourlyAt(5)->runInBackground();
|
||||
// Step 2: build ranked lists every hour at :15 (after scores are ready)
|
||||
$schedule->job(new RankBuildListsJob)->hourlyAt(15)->runInBackground();
|
||||
|
||||
// ── Ranking Engine V2 — runs every 30 min ──────────────────────────
|
||||
$schedule->command('nova:recalculate-rankings --sync-rank-scores')
|
||||
->everyThirtyMinutes()
|
||||
->name('ranking-v2')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// ── Rising Engine (Heat / Momentum) ─────────────────────────────────
|
||||
// Step 1: snapshot metric totals every hour at :00
|
||||
$schedule->command('nova:metrics-snapshot-hourly')
|
||||
->hourly()
|
||||
->name('metrics-snapshot-hourly')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
// Step 2: recalculate heat scores every 15 minutes
|
||||
$schedule->command('nova:recalculate-heat')
|
||||
->everyFifteenMinutes()
|
||||
->name('recalculate-heat')
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
// Step 3: prune old snapshots daily at 04:00
|
||||
$schedule->command('nova:prune-metric-snapshots --keep-days=7')
|
||||
->dailyAt('04:00');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
20
app/Events/Posts/ArtworkShared.php
Normal file
20
app/Events/Posts/ArtworkShared.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Posts;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ArtworkShared
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Post $post,
|
||||
public readonly Artwork $artwork,
|
||||
public readonly User $sharer,
|
||||
) {}
|
||||
}
|
||||
20
app/Events/Posts/PostCommented.php
Normal file
20
app/Events/Posts/PostCommented.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PostCommented
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Post $post,
|
||||
public readonly PostComment $comment,
|
||||
public readonly User $commenter,
|
||||
) {}
|
||||
}
|
||||
119
app/Http/Controllers/Admin/EarlyGrowthAdminController.php
Normal file
119
app/Http/Controllers/Admin/EarlyGrowthAdminController.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,15 @@ class ArtworkController extends Controller
|
||||
$user = $request->user();
|
||||
$data = $request->validated();
|
||||
|
||||
$categoryId = isset($data['category']) && ctype_digit((string) $data['category'])
|
||||
? (int) $data['category']
|
||||
: null;
|
||||
|
||||
$result = $drafts->createDraft(
|
||||
(int) $user->id,
|
||||
(string) $data['title'],
|
||||
isset($data['description']) ? (string) $data['description'] : null
|
||||
isset($data['description']) ? (string) $data['description'] : null,
|
||||
$categoryId
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
|
||||
223
app/Http/Controllers/Api/LinkPreviewController.php
Normal file
223
app/Http/Controllers/Api/LinkPreviewController.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\TransferException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LinkPreviewController extends Controller
|
||||
{
|
||||
private const TIMEOUT = 8; // seconds
|
||||
private const MAX_BYTES = 524_288; // 512 KB – enough to get the <head>
|
||||
private const USER_AGENT = 'Skinbase-LinkPreview/1.0 (+https://skinbase.org)';
|
||||
|
||||
/** Blocked IP ranges (SSRF protection). */
|
||||
private const BLOCKED_CIDRS = [
|
||||
'0.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'100.64.0.0/10',
|
||||
'127.0.0.0/8',
|
||||
'169.254.0.0/16',
|
||||
'172.16.0.0/12',
|
||||
'192.0.0.0/24',
|
||||
'192.168.0.0/16',
|
||||
'198.18.0.0/15',
|
||||
'198.51.100.0/24',
|
||||
'203.0.113.0/24',
|
||||
'240.0.0.0/4',
|
||||
'::1/128',
|
||||
'fc00::/7',
|
||||
'fe80::/10',
|
||||
];
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'url' => ['required', 'string', 'max:2048'],
|
||||
]);
|
||||
|
||||
$rawUrl = trim((string) $request->input('url'));
|
||||
|
||||
// Must be http(s)
|
||||
if (! preg_match('#^https?://#i', $rawUrl)) {
|
||||
return response()->json(['error' => 'Invalid URL scheme.'], 422);
|
||||
}
|
||||
|
||||
$parsed = parse_url($rawUrl);
|
||||
$host = $parsed['host'] ?? '';
|
||||
|
||||
if (empty($host)) {
|
||||
return response()->json(['error' => 'Invalid URL.'], 422);
|
||||
}
|
||||
|
||||
// Resolve hostname and block private/loopback IPs (SSRF protection)
|
||||
$resolved = gethostbyname($host);
|
||||
if ($this->isBlockedIp($resolved)) {
|
||||
return response()->json(['error' => 'URL not allowed.'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$client = new Client([
|
||||
'timeout' => self::TIMEOUT,
|
||||
'connect_timeout' => 4,
|
||||
'allow_redirects' => ['max' => 5, 'strict' => false],
|
||||
'headers' => [
|
||||
'User-Agent' => self::USER_AGENT,
|
||||
'Accept' => 'text/html,application/xhtml+xml',
|
||||
],
|
||||
'verify' => true,
|
||||
]);
|
||||
|
||||
$response = $client->get($rawUrl);
|
||||
$status = $response->getStatusCode();
|
||||
|
||||
if ($status < 200 || $status >= 400) {
|
||||
return response()->json(['error' => 'Could not fetch URL.'], 422);
|
||||
}
|
||||
|
||||
// Read up to MAX_BYTES – we only need the HTML <head>
|
||||
$body = '';
|
||||
$stream = $response->getBody();
|
||||
while (! $stream->eof() && strlen($body) < self::MAX_BYTES) {
|
||||
$body .= $stream->read(4096);
|
||||
}
|
||||
$stream->close();
|
||||
|
||||
} catch (TransferException $e) {
|
||||
return response()->json(['error' => 'Could not reach URL.'], 422);
|
||||
}
|
||||
|
||||
$preview = $this->extractMeta($body, $rawUrl);
|
||||
|
||||
return response()->json($preview);
|
||||
}
|
||||
|
||||
/** Extract OG / Twitter / fallback meta tags. */
|
||||
private function extractMeta(string $html, string $originalUrl): array
|
||||
{
|
||||
// Limit to roughly the <head> block for speed
|
||||
$head = substr($html, 0, 50_000);
|
||||
|
||||
$og = [];
|
||||
|
||||
// OG / Twitter meta tags
|
||||
preg_match_all(
|
||||
'/<meta\s[^>]*(?:property|name)\s*=\s*["\']([^"\']+)["\'][^>]*content\s*=\s*["\']([^"\']*)["\'][^>]*>/i',
|
||||
$head,
|
||||
$m1,
|
||||
PREG_SET_ORDER,
|
||||
);
|
||||
preg_match_all(
|
||||
'/<meta\s[^>]*content\s*=\s*["\']([^"\']*)["\'][^>]*(?:property|name)\s*=\s*["\']([^"\']+)["\'][^>]*>/i',
|
||||
$head,
|
||||
$m2,
|
||||
PREG_SET_ORDER,
|
||||
);
|
||||
|
||||
$allMeta = array_merge(
|
||||
array_map(fn ($r) => ['key' => strtolower($r[1]), 'value' => $r[2]], $m1),
|
||||
array_map(fn ($r) => ['key' => strtolower($r[2]), 'value' => $r[1]], $m2),
|
||||
);
|
||||
|
||||
$map = [];
|
||||
foreach ($allMeta as $entry) {
|
||||
$map[$entry['key']] ??= $entry['value'];
|
||||
}
|
||||
|
||||
// Canonical URL
|
||||
$canonical = $originalUrl;
|
||||
if (preg_match('/<link[^>]+rel\s*=\s*["\']canonical["\'][^>]+href\s*=\s*["\']([^"\']+)["\'][^>]*>/i', $head, $mc)) {
|
||||
$canonical = $mc[1];
|
||||
} elseif (preg_match('/<link[^>]+href\s*=\s*["\']([^"\']+)["\'][^>]+rel\s*=\s*["\']canonical["\'][^>]*>/i', $head, $mc)) {
|
||||
$canonical = $mc[1];
|
||||
}
|
||||
|
||||
// Title
|
||||
$title = $map['og:title']
|
||||
?? $map['twitter:title']
|
||||
?? null;
|
||||
if (! $title && preg_match('/<title[^>]*>([^<]+)<\/title>/i', $head, $mt)) {
|
||||
$title = trim(html_entity_decode($mt[1]));
|
||||
}
|
||||
|
||||
// Description
|
||||
$description = $map['og:description']
|
||||
?? $map['twitter:description']
|
||||
?? $map['description']
|
||||
?? null;
|
||||
|
||||
// Image
|
||||
$image = $map['og:image']
|
||||
?? $map['twitter:image']
|
||||
?? $map['twitter:image:src']
|
||||
?? null;
|
||||
|
||||
// Resolve relative image URL
|
||||
if ($image && ! preg_match('#^https?://#i', $image)) {
|
||||
$parsed = parse_url($originalUrl);
|
||||
$base = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? '');
|
||||
$image = $base . '/' . ltrim($image, '/');
|
||||
}
|
||||
|
||||
// Site name
|
||||
$siteName = $map['og:site_name'] ?? parse_url($originalUrl, PHP_URL_HOST) ?? null;
|
||||
|
||||
return [
|
||||
'url' => $canonical,
|
||||
'title' => $title ? html_entity_decode($title) : null,
|
||||
'description' => $description ? html_entity_decode($description) : null,
|
||||
'image' => $image,
|
||||
'site_name' => $siteName,
|
||||
];
|
||||
}
|
||||
|
||||
private function isBlockedIp(string $ip): bool
|
||||
{
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return true; // could not resolve
|
||||
}
|
||||
foreach (self::BLOCKED_CIDRS as $cidr) {
|
||||
if ($this->ipInCidr($ip, $cidr)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function ipInCidr(string $ip, string $cidr): bool
|
||||
{
|
||||
[$subnet, $bits] = explode('/', $cidr) + [1 => 32];
|
||||
|
||||
// IPv6
|
||||
if (str_contains($cidr, ':')) {
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||
return false;
|
||||
}
|
||||
$ipBin = inet_pton($ip);
|
||||
$subnetBin = inet_pton($subnet);
|
||||
if ($ipBin === false || $subnetBin === false) {
|
||||
return false;
|
||||
}
|
||||
$bits = (int) $bits;
|
||||
$mask = str_repeat("\xff", (int) ($bits / 8));
|
||||
$remain = $bits % 8;
|
||||
if ($remain) {
|
||||
$mask .= chr(0xff << (8 - $remain));
|
||||
}
|
||||
$mask = str_pad($mask, strlen($subnetBin), "\x00");
|
||||
return ($ipBin & $mask) === ($subnetBin & $mask);
|
||||
}
|
||||
|
||||
// IPv4
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return false;
|
||||
}
|
||||
$ipLong = ip2long($ip);
|
||||
$subnetLong = ip2long($subnet);
|
||||
$maskLong = $bits == 32 ? -1 : ~((1 << (32 - (int) $bits)) - 1);
|
||||
return ($ipLong & $maskLong) === ($subnetLong & $maskLong);
|
||||
}
|
||||
}
|
||||
61
app/Http/Controllers/Api/NotificationController.php
Normal file
61
app/Http/Controllers/Api/NotificationController.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Services\Posts\NotificationDigestService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
/**
|
||||
* GET /api/notifications — digestd notification list
|
||||
* POST /api/notifications/read-all — mark all unread as read
|
||||
* POST /api/notifications/{id}/read — mark single as read
|
||||
*/
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function __construct(private NotificationDigestService $digest) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$notifications = $user->notifications()
|
||||
->latest()
|
||||
->limit(200) // aggregate from last 200 raw notifs
|
||||
->get();
|
||||
|
||||
$digested = $this->digest->aggregate($notifications);
|
||||
|
||||
// Simple manual pagination on the digested array
|
||||
$perPage = 20;
|
||||
$total = count($digested);
|
||||
$sliced = array_slice($digested, ($page - 1) * $perPage, $perPage);
|
||||
$unread = $user->unreadNotifications()->count();
|
||||
|
||||
return response()->json([
|
||||
'data' => array_values($sliced),
|
||||
'unread_count' => $unread,
|
||||
'meta' => [
|
||||
'total' => $total,
|
||||
'current_page' => $page,
|
||||
'last_page' => (int) ceil($total / $perPage) ?: 1,
|
||||
'per_page' => $perPage,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function readAll(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->unreadNotifications->markAsRead();
|
||||
return response()->json(['message' => 'All notifications marked as read.']);
|
||||
}
|
||||
|
||||
public function markRead(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$notif = $request->user()->notifications()->findOrFail($id);
|
||||
$notif->markAsRead();
|
||||
return response()->json(['message' => 'Notification marked as read.']);
|
||||
}
|
||||
}
|
||||
44
app/Http/Controllers/Api/Posts/PostAnalyticsController.php
Normal file
44
app/Http/Controllers/Api/Posts/PostAnalyticsController.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Services\Posts\PostAnalyticsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* POST /api/posts/{id}/impression — record an impression (throttled)
|
||||
* GET /api/posts/{id}/analytics — owner analytics summary
|
||||
*/
|
||||
class PostAnalyticsController extends Controller
|
||||
{
|
||||
public function __construct(private PostAnalyticsService $analytics) {}
|
||||
|
||||
public function impression(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::where('status', Post::STATUS_PUBLISHED)->findOrFail($id);
|
||||
|
||||
// Session key: authenticated user ID or hashed IP
|
||||
$sessionKey = $request->user()
|
||||
? 'u:' . $request->user()->id
|
||||
: 'ip:' . md5($request->ip());
|
||||
|
||||
$counted = $this->analytics->trackImpression($post, $sessionKey);
|
||||
|
||||
return response()->json(['counted' => $counted]);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
|
||||
// Only the post owner can view analytics
|
||||
if ($request->user()?->id !== $post->user_id) {
|
||||
abort(403, 'You do not own this post.');
|
||||
}
|
||||
|
||||
return response()->json(['data' => $this->analytics->getSummary($post)]);
|
||||
}
|
||||
}
|
||||
122
app/Http/Controllers/Api/Posts/PostCommentController.php
Normal file
122
app/Http/Controllers/Api/Posts/PostCommentController.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Events\Posts\PostCommented;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Posts\CreateCommentRequest;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\Posts\PostCountersService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class PostCommentController extends Controller
|
||||
{
|
||||
public function __construct(private PostCountersService $counters) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// List
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function index(Request $request, int $postId): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($postId);
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$comments = PostComment::with(['user', 'user.profile'])
|
||||
->where('post_id', $post->id)
|
||||
->orderByDesc('is_highlighted') // highlighted first
|
||||
->orderBy('created_at')
|
||||
->paginate(20, ['*'], 'page', $page);
|
||||
|
||||
$formatted = $comments->getCollection()->map(fn ($c) => $this->formatComment($c));
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'meta' => [
|
||||
'total' => $comments->total(),
|
||||
'current_page' => $comments->currentPage(),
|
||||
'last_page' => $comments->lastPage(),
|
||||
'per_page' => $comments->perPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Store
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function store(CreateCommentRequest $request, int $postId): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Rate limit: 30 comments per hour
|
||||
$key = 'comment_post:' . $user->id;
|
||||
if (RateLimiter::tooManyAttempts($key, 30)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
return response()->json([
|
||||
'message' => "You're commenting too quickly. Please wait {$seconds} seconds.",
|
||||
], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 3600);
|
||||
|
||||
$post = Post::findOrFail($postId);
|
||||
$body = ContentSanitizer::render($request->input('body'));
|
||||
|
||||
$comment = PostComment::create([
|
||||
'post_id' => $post->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => $body,
|
||||
]);
|
||||
|
||||
$this->counters->incrementComments($post);
|
||||
|
||||
// Fire event for notification
|
||||
if ($post->user_id !== $user->id) {
|
||||
event(new PostCommented($post, $comment, $user));
|
||||
}
|
||||
|
||||
$comment->load(['user', 'user.profile']);
|
||||
|
||||
return response()->json(['comment' => $this->formatComment($comment)], 201);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Destroy
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function destroy(Request $request, int $postId, int $commentId): JsonResponse
|
||||
{
|
||||
$comment = PostComment::where('post_id', $postId)->findOrFail($commentId);
|
||||
Gate::authorize('delete', $comment);
|
||||
|
||||
$comment->delete();
|
||||
$this->counters->decrementComments(Post::findOrFail($postId));
|
||||
|
||||
return response()->json(['message' => 'Comment deleted.']);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Format
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function formatComment(PostComment $comment): array
|
||||
{
|
||||
return [
|
||||
'id' => $comment->id,
|
||||
'body' => $comment->body,
|
||||
'is_highlighted' => (bool) $comment->is_highlighted,
|
||||
'created_at' => $comment->created_at->toISOString(),
|
||||
'author' => [
|
||||
'id' => $comment->user->id,
|
||||
'username' => $comment->user->username,
|
||||
'name' => $comment->user->name,
|
||||
'avatar' => $comment->user->profile?->avatar_url ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostComment;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* POST /api/posts/{post_id}/comments/{comment_id}/highlight
|
||||
* DELETE /api/posts/{post_id}/comments/{comment_id}/highlight
|
||||
*
|
||||
* Only the post owner may highlight/un-highlight.
|
||||
* Only 1 highlighted comment per post is allowed at a time.
|
||||
*/
|
||||
class PostCommentHighlightController extends Controller
|
||||
{
|
||||
public function highlight(Request $request, int $postId, int $commentId): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($postId);
|
||||
$comment = PostComment::where('post_id', $postId)->findOrFail($commentId);
|
||||
|
||||
if ($request->user()->id !== $post->user_id) {
|
||||
abort(403, 'Only the post owner can highlight comments.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($post, $comment) {
|
||||
// Remove any existing highlight on this post
|
||||
PostComment::where('post_id', $post->id)
|
||||
->where('is_highlighted', true)
|
||||
->update(['is_highlighted' => false]);
|
||||
|
||||
$comment->update(['is_highlighted' => true]);
|
||||
});
|
||||
|
||||
return response()->json(['message' => 'Comment highlighted.', 'comment_id' => $comment->id]);
|
||||
}
|
||||
|
||||
public function unhighlight(Request $request, int $postId, int $commentId): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($postId);
|
||||
$comment = PostComment::where('post_id', $postId)->findOrFail($commentId);
|
||||
|
||||
if ($request->user()->id !== $post->user_id) {
|
||||
abort(403, 'Only the post owner can remove comment highlights.');
|
||||
}
|
||||
|
||||
$comment->update(['is_highlighted' => false]);
|
||||
|
||||
return response()->json(['message' => 'Highlight removed.', 'comment_id' => $comment->id]);
|
||||
}
|
||||
}
|
||||
92
app/Http/Controllers/Api/Posts/PostController.php
Normal file
92
app/Http/Controllers/Api/Posts/PostController.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Posts\CreatePostRequest;
|
||||
use App\Http\Requests\Posts\UpdatePostRequest;
|
||||
use App\Models\Post;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use App\Services\Posts\PostService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class PostController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PostService $postService,
|
||||
private PostFeedService $feedService,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Create
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function store(CreatePostRequest $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Rate limit: 10 post creations per hour
|
||||
$key = 'create_post:' . $user->id;
|
||||
if (RateLimiter::tooManyAttempts($key, 10)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
return response()->json([
|
||||
'message' => "You're posting too quickly. Please wait {$seconds} seconds.",
|
||||
], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 3600);
|
||||
|
||||
Gate::authorize('create', Post::class);
|
||||
|
||||
$post = $this->postService->createPost(
|
||||
user: $user,
|
||||
type: $request->input('type', Post::TYPE_TEXT),
|
||||
visibility: $request->input('visibility', Post::VISIBILITY_PUBLIC),
|
||||
body: $request->input('body'),
|
||||
targets: $request->input('targets', []),
|
||||
linkPreview: $request->input('link_preview'),
|
||||
taggedUsers: $request->input('tagged_users'), publishAt: $request->filled('publish_at') ? Carbon::parse($request->input('publish_at')) : null, );
|
||||
|
||||
$post->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']);
|
||||
|
||||
return response()->json([
|
||||
'post' => $this->feedService->formatPost($post, $user->id),
|
||||
], 201);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Update
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function update(UpdatePostRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
Gate::authorize('update', $post);
|
||||
|
||||
$updated = $this->postService->updatePost(
|
||||
post: $post,
|
||||
body: $request->input('body'),
|
||||
visibility: $request->input('visibility'),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'post' => $this->feedService->formatPost($updated->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']), $request->user()?->id),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Delete
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
Gate::authorize('delete', $post);
|
||||
|
||||
$this->postService->deletePost($post);
|
||||
|
||||
return response()->json(['message' => 'Post deleted.']);
|
||||
}
|
||||
}
|
||||
60
app/Http/Controllers/Api/Posts/PostFeedController.php
Normal file
60
app/Http/Controllers/Api/Posts/PostFeedController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PostFeedController extends Controller
|
||||
{
|
||||
public function __construct(private PostFeedService $feedService) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Profile feed — GET /api/posts/profile/{username}
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function profile(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$profileUser = User::where('username', $username)->firstOrFail();
|
||||
$viewerId = $request->user()?->id;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$paginated = $this->feedService->getProfileFeed($profileUser, $viewerId, $page);
|
||||
|
||||
$formatted = collect($paginated['data'])
|
||||
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'meta' => $paginated['meta'],
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Following feed — GET /api/posts/following
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function following(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$filter = $request->query('filter', 'all');
|
||||
|
||||
$result = $this->feedService->getFollowingFeed($user, $page, $filter);
|
||||
|
||||
$viewerId = $user->id;
|
||||
$formatted = array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $viewerId),
|
||||
$result['data'],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'meta' => $result['meta'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
67
app/Http/Controllers/Api/Posts/PostPinController.php
Normal file
67
app/Http/Controllers/Api/Posts/PostPinController.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
/**
|
||||
* POST /api/posts/{id}/pin
|
||||
* DELETE /api/posts/{id}/pin
|
||||
*/
|
||||
class PostPinController extends Controller
|
||||
{
|
||||
private const MAX_PINNED = 3;
|
||||
|
||||
public function pin(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::where('status', Post::STATUS_PUBLISHED)->findOrFail($id);
|
||||
Gate::authorize('update', $post);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Count existing pinned posts
|
||||
$pinnedCount = Post::where('user_id', $user->id)
|
||||
->where('is_pinned', true)
|
||||
->count();
|
||||
|
||||
if ($post->is_pinned) {
|
||||
return response()->json(['message' => 'Post is already pinned.'], 409);
|
||||
}
|
||||
|
||||
if ($pinnedCount >= self::MAX_PINNED) {
|
||||
return response()->json([
|
||||
'message' => 'You can pin a maximum of ' . self::MAX_PINNED . ' posts.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$nextOrder = Post::where('user_id', $user->id)
|
||||
->where('is_pinned', true)
|
||||
->max('pinned_order') ?? 0;
|
||||
|
||||
$post->update([
|
||||
'is_pinned' => true,
|
||||
'pinned_order' => $nextOrder + 1,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Post pinned.', 'post_id' => $post->id]);
|
||||
}
|
||||
|
||||
public function unpin(int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
Gate::authorize('update', $post);
|
||||
|
||||
if (! $post->is_pinned) {
|
||||
return response()->json(['message' => 'Post is not pinned.'], 409);
|
||||
}
|
||||
|
||||
$post->update(['is_pinned' => false, 'pinned_order' => null]);
|
||||
|
||||
return response()->json(['message' => 'Post unpinned.', 'post_id' => $post->id]);
|
||||
}
|
||||
}
|
||||
75
app/Http/Controllers/Api/Posts/PostReactionController.php
Normal file
75
app/Http/Controllers/Api/Posts/PostReactionController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostReaction;
|
||||
use App\Services\Posts\PostCountersService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class PostReactionController extends Controller
|
||||
{
|
||||
public function __construct(private PostCountersService $counters) {}
|
||||
|
||||
/**
|
||||
* POST /api/posts/{id}/reactions
|
||||
* payload: { reaction: 'like' }
|
||||
*/
|
||||
public function store(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$key = 'react_post:' . $user->id;
|
||||
if (RateLimiter::tooManyAttempts($key, 60)) {
|
||||
return response()->json(['message' => 'Too many reactions. Please slow down.'], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 3600);
|
||||
|
||||
$post = Post::findOrFail($id);
|
||||
$reaction = $request->input('reaction', 'like');
|
||||
|
||||
$existing = PostReaction::where('post_id', $post->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('reaction', $reaction)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json(['message' => 'Already reacted.', 'reactions_count' => $post->reactions_count], 200);
|
||||
}
|
||||
|
||||
PostReaction::create([
|
||||
'post_id' => $post->id,
|
||||
'user_id' => $user->id,
|
||||
'reaction' => $reaction,
|
||||
]);
|
||||
|
||||
$this->counters->incrementReactions($post);
|
||||
$post->refresh();
|
||||
|
||||
return response()->json(['reactions_count' => $post->reactions_count, 'viewer_liked' => true], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/posts/{id}/reactions/{reaction}
|
||||
*/
|
||||
public function destroy(Request $request, int $id, string $reaction = 'like'): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$post = Post::findOrFail($id);
|
||||
|
||||
$deleted = PostReaction::where('post_id', $post->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('reaction', $reaction)
|
||||
->delete();
|
||||
|
||||
if ($deleted) {
|
||||
$this->counters->decrementReactions($post);
|
||||
$post->refresh();
|
||||
}
|
||||
|
||||
return response()->json(['reactions_count' => $post->reactions_count, 'viewer_liked' => false]);
|
||||
}
|
||||
}
|
||||
49
app/Http/Controllers/Api/Posts/PostReportController.php
Normal file
49
app/Http/Controllers/Api/Posts/PostReportController.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostReport;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class PostReportController extends Controller
|
||||
{
|
||||
/**
|
||||
* POST /api/posts/{id}/report
|
||||
* payload: { reason, message? }
|
||||
*/
|
||||
public function store(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$post = Post::findOrFail($id);
|
||||
|
||||
Gate::authorize('report', $post);
|
||||
|
||||
$request->validate([
|
||||
'reason' => ['required', 'string', 'max:64'],
|
||||
'message' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
// Unique report per user+post
|
||||
$existing = PostReport::where('post_id', $post->id)
|
||||
->where('reporter_user_id', $user->id)
|
||||
->exists();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json(['message' => 'You have already reported this post.'], 409);
|
||||
}
|
||||
|
||||
PostReport::create([
|
||||
'post_id' => $post->id,
|
||||
'reporter_user_id' => $user->id,
|
||||
'reason' => $request->input('reason'),
|
||||
'message' => $request->input('message'),
|
||||
'status' => 'open',
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Report submitted. Thank you for helping keep Skinbase safe.'], 201);
|
||||
}
|
||||
}
|
||||
69
app/Http/Controllers/Api/Posts/PostSaveController.php
Normal file
69
app/Http/Controllers/Api/Posts/PostSaveController.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostSave;
|
||||
use App\Services\Posts\PostCountersService;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* POST /api/posts/{id}/save
|
||||
* DELETE /api/posts/{id}/save
|
||||
* GET /api/posts/saved
|
||||
*/
|
||||
class PostSaveController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PostCountersService $counters,
|
||||
private PostFeedService $feedService,
|
||||
) {}
|
||||
|
||||
public function save(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::where('status', Post::STATUS_PUBLISHED)->findOrFail($id);
|
||||
$user = $request->user();
|
||||
|
||||
if (PostSave::where('post_id', $post->id)->where('user_id', $user->id)->exists()) {
|
||||
return response()->json(['message' => 'Already saved.', 'saved' => true], 200);
|
||||
}
|
||||
|
||||
PostSave::create(['post_id' => $post->id, 'user_id' => $user->id]);
|
||||
$this->counters->incrementSaves($post);
|
||||
|
||||
return response()->json(['message' => 'Post saved.', 'saved' => true, 'saves_count' => $post->fresh()->saves_count]);
|
||||
}
|
||||
|
||||
public function unsave(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
$user = $request->user();
|
||||
$save = PostSave::where('post_id', $post->id)->where('user_id', $user->id)->first();
|
||||
|
||||
if (! $save) {
|
||||
return response()->json(['message' => 'Not saved.', 'saved' => false], 200);
|
||||
}
|
||||
|
||||
$save->delete();
|
||||
$this->counters->decrementSaves($post);
|
||||
|
||||
return response()->json(['message' => 'Post unsaved.', 'saved' => false, 'saves_count' => $post->fresh()->saves_count]);
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$result = $this->feedService->getSavedFeed($user, $page);
|
||||
|
||||
$formatted = array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $user->id),
|
||||
$result['data'],
|
||||
);
|
||||
|
||||
return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]);
|
||||
}
|
||||
}
|
||||
85
app/Http/Controllers/Api/Posts/PostSearchController.php
Normal file
85
app/Http/Controllers/Api/Posts/PostSearchController.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Post;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* GET /api/feed/search?q=...
|
||||
*
|
||||
* Searches posts body + hashtags via Meilisearch (Laravel Scout).
|
||||
* Falls back to a simple LIKE query if Scout is unavailable.
|
||||
*/
|
||||
class PostSearchController extends Controller
|
||||
{
|
||||
public function __construct(private PostFeedService $feedService) {}
|
||||
|
||||
public function search(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'q' => ['required', 'string', 'min:2', 'max:100'],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
]);
|
||||
|
||||
$query = trim($request->input('q'));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$perPage = 20;
|
||||
$viewerId = $request->user()?->id;
|
||||
|
||||
// Scout search (Meilisearch)
|
||||
try {
|
||||
$results = Post::search($query)
|
||||
->where('visibility', Post::VISIBILITY_PUBLIC)
|
||||
->where('status', Post::STATUS_PUBLISHED)
|
||||
->paginate($perPage, 'page', $page);
|
||||
|
||||
// Load relations
|
||||
$results->load($this->feedService->publicEagerLoads());
|
||||
|
||||
$formatted = $results->getCollection()
|
||||
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'query' => $query,
|
||||
'meta' => [
|
||||
'total' => $results->total(),
|
||||
'current_page' => $results->currentPage(),
|
||||
'last_page' => $results->lastPage(),
|
||||
'per_page' => $results->perPage(),
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Fallback: basic LIKE search on body
|
||||
$paginated = Post::with($this->feedService->publicEagerLoads())
|
||||
->where('status', Post::STATUS_PUBLISHED)
|
||||
->where('visibility', Post::VISIBILITY_PUBLIC)
|
||||
->where(function ($q) use ($query) {
|
||||
$q->where('body', 'like', '%' . $query . '%')
|
||||
->orWhereHas('hashtags', fn ($hq) => $hq->where('tag', 'like', '%' . mb_strtolower($query) . '%'));
|
||||
})
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
$formatted = $paginated->getCollection()
|
||||
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $formatted,
|
||||
'query' => $query,
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Api/Posts/PostShareController.php
Normal file
58
app/Http/Controllers/Api/Posts/PostShareController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Events\Posts\ArtworkShared;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Posts\ShareArtworkRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use App\Services\Posts\PostShareService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class PostShareController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PostShareService $shareService,
|
||||
private PostFeedService $feedService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* POST /api/posts/share/artwork/{artwork_id}
|
||||
* payload: { body?, visibility }
|
||||
*/
|
||||
public function shareArtwork(ShareArtworkRequest $request, int $artworkId): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$artwork = Artwork::findOrFail($artworkId);
|
||||
|
||||
// Rate limit: 10 artwork shares per hour
|
||||
$key = 'share_artwork:' . $user->id;
|
||||
if (RateLimiter::tooManyAttempts($key, 10)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
return response()->json([
|
||||
'message' => "You're sharing too quickly. Please wait {$seconds} seconds.",
|
||||
], 429);
|
||||
}
|
||||
RateLimiter::hit($key, 3600);
|
||||
|
||||
$post = $this->shareService->shareArtwork(
|
||||
user: $user,
|
||||
artwork: $artwork,
|
||||
body: $request->input('body'),
|
||||
visibility: $request->input('visibility', 'public'),
|
||||
);
|
||||
|
||||
$post->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']);
|
||||
|
||||
// Notify original artwork owner (unless self-share)
|
||||
if ($artwork->user_id !== $user->id) {
|
||||
event(new ArtworkShared($post, $artwork, $user));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'post' => $this->feedService->formatPost($post, $user->id),
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Posts\PostFeedService;
|
||||
use App\Services\Posts\PostHashtagService;
|
||||
use App\Services\Posts\PostTrendingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* GET /api/feed/trending
|
||||
* GET /api/feed/hashtag/{tag}
|
||||
* GET /api/feed/hashtags/trending
|
||||
*/
|
||||
class PostTrendingFeedController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PostTrendingService $trendingService,
|
||||
private PostFeedService $feedService,
|
||||
private PostHashtagService $hashtagService,
|
||||
) {}
|
||||
|
||||
public function trending(Request $request): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$viewer = $request->user()?->id;
|
||||
|
||||
$result = $this->trendingService->getTrending($viewer, $page);
|
||||
|
||||
$formatted = array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $viewer),
|
||||
$result['data'],
|
||||
);
|
||||
|
||||
return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]);
|
||||
}
|
||||
|
||||
public function hashtag(Request $request, string $tag): JsonResponse
|
||||
{
|
||||
$tag = mb_strtolower(preg_replace('/[^A-Za-z0-9_]/', '', $tag));
|
||||
if (strlen($tag) < 2 || strlen($tag) > 64) {
|
||||
return response()->json(['message' => 'Invalid hashtag.'], 422);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$viewer = $request->user()?->id;
|
||||
|
||||
$result = $this->feedService->getHashtagFeed($tag, $viewer, $page);
|
||||
|
||||
$formatted = array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $viewer),
|
||||
$result['data'],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'tag' => $tag,
|
||||
'data' => array_values($formatted),
|
||||
'meta' => $result['meta'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function trendingHashtags(): JsonResponse
|
||||
{
|
||||
$tags = Cache::remember('trending_hashtags', 300, function () {
|
||||
return $this->hashtagService->trending(10, 24);
|
||||
});
|
||||
|
||||
return response()->json(['hashtags' => $tags]);
|
||||
}
|
||||
}
|
||||
177
app/Http/Controllers/Api/ProfileApiController.php
Normal file
177
app/Http/Controllers/Api/ProfileApiController.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\ThumbnailService;
|
||||
use App\Support\AvatarUrl;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* ProfileApiController
|
||||
* JSON API endpoints for Profile page v2 tabs.
|
||||
*/
|
||||
final class ProfileApiController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/profile/{username}/artworks
|
||||
* Returns cursor-paginated artworks for the profile page tabs.
|
||||
* Supports: sort=latest|trending|rising|views|favs, cursor=...
|
||||
*/
|
||||
public function artworks(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$user = $this->resolveUser($username);
|
||||
if (! $user) {
|
||||
return response()->json(['error' => 'User not found'], 404);
|
||||
}
|
||||
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
$sort = $request->input('sort', 'latest');
|
||||
|
||||
$query = Artwork::with('user:id,name,username')
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at');
|
||||
|
||||
if (! $isOwner) {
|
||||
$query->where('is_public', true)->where('is_approved', true)->whereNotNull('published_at');
|
||||
}
|
||||
|
||||
$query = match ($sort) {
|
||||
'trending' => $query->orderByDesc('ranking_score'),
|
||||
'rising' => $query->orderByDesc('heat_score'),
|
||||
'views' => $query->orderByDesc('view_count'),
|
||||
'favs' => $query->orderByDesc('favourite_count'),
|
||||
default => $query->orderByDesc('published_at'),
|
||||
};
|
||||
|
||||
$perPage = 24;
|
||||
$paginator = $query->cursorPaginate($perPage);
|
||||
|
||||
$data = collect($paginator->items())->map(function (Artwork $art) {
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
return [
|
||||
'id' => $art->id,
|
||||
'name' => $art->title,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
'username' => $art->user->username ?? null,
|
||||
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
|
||||
'published_at' => $art->published_at,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'next_cursor' => $paginator->nextCursor()?->encode(),
|
||||
'has_more' => $paginator->hasMorePages(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/profile/{username}/favourites
|
||||
* Returns cursor-paginated favourites for the profile.
|
||||
*/
|
||||
public function favourites(Request $request, string $username): JsonResponse
|
||||
{
|
||||
if (! Schema::hasTable('user_favorites')) {
|
||||
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
||||
}
|
||||
|
||||
$user = $this->resolveUser($username);
|
||||
if (! $user) {
|
||||
return response()->json(['error' => 'User not found'], 404);
|
||||
}
|
||||
|
||||
$perPage = 24;
|
||||
$cursor = $request->input('cursor');
|
||||
|
||||
$favIds = DB::table('user_favorites as uf')
|
||||
->join('artworks as a', 'a.id', '=', 'uf.artwork_id')
|
||||
->where('uf.user_id', $user->id)
|
||||
->whereNull('a.deleted_at')
|
||||
->where('a.is_public', true)
|
||||
->where('a.is_approved', true)
|
||||
->orderByDesc('uf.created_at')
|
||||
->offset($cursor ? (int) base64_decode($cursor) : 0)
|
||||
->limit($perPage + 1)
|
||||
->pluck('a.id');
|
||||
|
||||
$hasMore = $favIds->count() > $perPage;
|
||||
$favIds = $favIds->take($perPage);
|
||||
|
||||
if ($favIds->isEmpty()) {
|
||||
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
||||
}
|
||||
|
||||
$indexed = Artwork::with('user:id,name,username')
|
||||
->whereIn('id', $favIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$data = $favIds->filter(fn ($id) => $indexed->has($id))->map(function ($id) use ($indexed) {
|
||||
$art = $indexed[$id];
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
return [
|
||||
'id' => $art->id,
|
||||
'name' => $art->title,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
'username' => $art->user->username ?? null,
|
||||
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'next_cursor' => null, // Simple offset pagination for now
|
||||
'has_more' => $hasMore,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/profile/{username}/stats
|
||||
* Returns profile statistics.
|
||||
*/
|
||||
public function stats(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$user = $this->resolveUser($username);
|
||||
if (! $user) {
|
||||
return response()->json(['error' => 'User not found'], 404);
|
||||
}
|
||||
|
||||
$stats = null;
|
||||
if (Schema::hasTable('user_statistics')) {
|
||||
$stats = DB::table('user_statistics')->where('user_id', $user->id)->first();
|
||||
}
|
||||
|
||||
$followerCount = 0;
|
||||
if (Schema::hasTable('user_followers')) {
|
||||
$followerCount = DB::table('user_followers')->where('user_id', $user->id)->count();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'stats' => $stats,
|
||||
'follower_count' => $followerCount,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveUser(string $username): ?User
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||
}
|
||||
}
|
||||
@@ -31,13 +31,14 @@ final class UserSearchController extends Controller
|
||||
->where('is_active', 1)
|
||||
->whereNull('deleted_at')
|
||||
->where(function ($qb) use ($q) {
|
||||
$qb->whereRaw('LOWER(username) LIKE ?', ['%' . strtolower($q) . '%']);
|
||||
$qb->whereRaw('LOWER(username) LIKE ?', ['%' . strtolower($q) . '%'])
|
||||
->orWhereRaw('LOWER(name) LIKE ?', ['%' . strtolower($q) . '%']);
|
||||
})
|
||||
->with(['profile', 'statistics'])
|
||||
->orderByRaw('LOWER(username) = ? DESC', [strtolower($q)]) // exact match first
|
||||
->orderBy('username')
|
||||
->limit($perPage)
|
||||
->get(['id', 'username']);
|
||||
->get(['id', 'username', 'name']);
|
||||
|
||||
$data = $users->map(function (User $user) {
|
||||
$username = strtolower((string) ($user->username ?? ''));
|
||||
@@ -48,6 +49,7 @@ final class UserSearchController extends Controller
|
||||
'id' => $user->id,
|
||||
'type' => 'user',
|
||||
'username' => $username,
|
||||
'name' => $user->name ?? $username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $user->id, $avatarHash, 64),
|
||||
'uploads_count' => $uploadsCount,
|
||||
'profile_url' => '/@' . $username,
|
||||
|
||||
@@ -7,29 +7,33 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\Recommendations\HybridSimilarArtworksService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* GET /api/art/{id}/similar
|
||||
*
|
||||
* Returns up to 12 similar artworks based on:
|
||||
* 1. Tag overlap (primary signal)
|
||||
* 2. Same category
|
||||
* 3. Similar orientation
|
||||
* Returns up to 12 similar artworks using the hybrid recommender (precomputed lists)
|
||||
* with a Meilisearch-based fallback if no precomputed data exists.
|
||||
*
|
||||
* Uses Meilisearch via ArtworkSearchService for fast retrieval.
|
||||
* Current artwork and its creator are excluded from results.
|
||||
* Query params:
|
||||
* ?type=similar (default) | visual | tags | behavior
|
||||
*
|
||||
* Priority (default):
|
||||
* 1. Hybrid precomputed (tag + behavior + optional vector)
|
||||
* 2. Meilisearch tag-overlap fallback (legacy)
|
||||
*/
|
||||
final class SimilarArtworksController extends Controller
|
||||
{
|
||||
private const LIMIT = 12;
|
||||
/** Spec §5: cache similar artworks 30–60 min; using config with 30 min default. */
|
||||
private const CACHE_TTL = 1800; // 30 minutes
|
||||
|
||||
public function __construct(private readonly ArtworkSearchService $search) {}
|
||||
public function __construct(
|
||||
private readonly ArtworkSearchService $search,
|
||||
private readonly HybridSimilarArtworksService $hybridService,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): JsonResponse
|
||||
public function __invoke(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::public()
|
||||
->published()
|
||||
@@ -40,22 +44,64 @@ final class SimilarArtworksController extends Controller
|
||||
return response()->json(['error' => 'Artwork not found'], 404);
|
||||
}
|
||||
|
||||
$cacheKey = "api.similar.{$artwork->id}";
|
||||
$type = $request->query('type');
|
||||
$validTypes = ['similar', 'visual', 'tags', 'behavior'];
|
||||
if ($type !== null && ! in_array($type, $validTypes, true)) {
|
||||
$type = null; // ignore invalid, fall through to default
|
||||
}
|
||||
|
||||
$items = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork) {
|
||||
return $this->findSimilar($artwork);
|
||||
});
|
||||
// Service handles its own caching (6h TTL), no extra controller-level cache
|
||||
$hybridResults = $this->hybridService->forArtwork($artwork->id, self::LIMIT, $type);
|
||||
|
||||
if ($hybridResults->isNotEmpty()) {
|
||||
// Eager-load relations needed for formatting
|
||||
$ids = $hybridResults->pluck('id')->all();
|
||||
$loaded = Artwork::query()
|
||||
->whereIn('id', $ids)
|
||||
->with(['tags:id,slug', 'stats', 'user:id,name', 'user.profile:user_id,avatar_hash'])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$items = $hybridResults->values()->map(function (Artwork $a) use ($loaded) {
|
||||
$full = $loaded->get($a->id) ?? $a;
|
||||
return $this->formatArtwork($full);
|
||||
})->all();
|
||||
|
||||
return response()->json(['data' => $items]);
|
||||
}
|
||||
|
||||
private function findSimilar(Artwork $artwork): array
|
||||
// Fall back to Meilisearch tag-overlap search
|
||||
$items = $this->findSimilarViaSearch($artwork);
|
||||
|
||||
return response()->json(['data' => $items]);
|
||||
}
|
||||
|
||||
private function formatArtwork(Artwork $artwork): array
|
||||
{
|
||||
return [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb' => $artwork->thumbUrl('md'),
|
||||
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
|
||||
'author' => $artwork->user?->name ?? 'Artist',
|
||||
'author_avatar' => $artwork->user?->profile?->avatar_url,
|
||||
'author_id' => $artwork->user_id,
|
||||
'orientation' => $this->orientation($artwork),
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy Meilisearch-based similar artworks (fallback).
|
||||
*/
|
||||
private function findSimilarViaSearch(Artwork $artwork): array
|
||||
{
|
||||
$tagSlugs = $artwork->tags->pluck('slug')->values()->all();
|
||||
$categorySlugs = $artwork->categories->pluck('slug')->values()->all();
|
||||
$srcOrientation = $this->orientation($artwork);
|
||||
|
||||
// Build Meilisearch filter: exclude self and same creator
|
||||
$filterParts = [
|
||||
'is_public = true',
|
||||
'is_approved = true',
|
||||
@@ -63,7 +109,6 @@ final class SimilarArtworksController extends Controller
|
||||
'author_id != ' . $artwork->user_id,
|
||||
];
|
||||
|
||||
// Priority 1: tag overlap (OR match across tags)
|
||||
if ($tagSlugs !== []) {
|
||||
$tagFilter = implode(' OR ', array_map(
|
||||
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
|
||||
@@ -71,7 +116,6 @@ final class SimilarArtworksController extends Controller
|
||||
));
|
||||
$filterParts[] = '(' . $tagFilter . ')';
|
||||
} elseif ($categorySlugs !== []) {
|
||||
// Fallback to category if no tags
|
||||
$catFilter = implode(' OR ', array_map(
|
||||
fn (string $c): string => 'category = "' . addslashes($c) . '"',
|
||||
$categorySlugs
|
||||
@@ -79,7 +123,6 @@ final class SimilarArtworksController extends Controller
|
||||
$filterParts[] = '(' . $catFilter . ')';
|
||||
}
|
||||
|
||||
// ── Fetch 200-candidate pool from Meilisearch ─────────────────────────
|
||||
$results = Artwork::search('')
|
||||
->options([
|
||||
'filter' => implode(' AND ', $filterParts),
|
||||
@@ -90,9 +133,6 @@ final class SimilarArtworksController extends Controller
|
||||
$collection = $results->getCollection();
|
||||
$collection->load(['tags:id,slug', 'stats', 'user:id,name', 'user.profile:user_id,avatar_hash']);
|
||||
|
||||
// ── PHP reranking ──────────────────────────────────────────────────────
|
||||
// Weights: tag_overlap ×0.60, orientation_bonus +0.10, resolution_bonus
|
||||
// +0.05, popularity (log-views) ≤0.15, freshness (exp decay) ×0.10
|
||||
$srcTagSet = array_flip($tagSlugs);
|
||||
$srcW = (int) ($artwork->width ?? 0);
|
||||
$srcH = (int) ($artwork->height ?? 0);
|
||||
@@ -103,15 +143,12 @@ final class SimilarArtworksController extends Controller
|
||||
$cTagSlugs = $candidate->tags->pluck('slug')->all();
|
||||
$cTagSet = array_flip($cTagSlugs);
|
||||
|
||||
// Tag overlap (Sørensen–Dice-like)
|
||||
$common = count(array_intersect_key($srcTagSet, $cTagSet));
|
||||
$total = max(1, count($srcTagSet) + count($cTagSet) - $common);
|
||||
$tagOverlap = $common / $total;
|
||||
|
||||
// Orientation bonus
|
||||
$orientBonus = $this->orientation($candidate) === $srcOrientation ? 0.10 : 0.0;
|
||||
|
||||
// Resolution proximity bonus (both axes within 25 %)
|
||||
$cW = (int) ($candidate->width ?? 0);
|
||||
$cH = (int) ($candidate->height ?? 0);
|
||||
$resBonus = ($srcW > 0 && $srcH > 0 && $cW > 0 && $cH > 0
|
||||
@@ -119,11 +156,9 @@ final class SimilarArtworksController extends Controller
|
||||
&& abs($cH - $srcH) / $srcH <= 0.25
|
||||
) ? 0.05 : 0.0;
|
||||
|
||||
// Popularity boost (log-normalised views, capped at 0.15)
|
||||
$views = max(0, (int) ($candidate->stats?->views ?? 0));
|
||||
$popularity = min(0.15, log(1 + $views) / 13.0);
|
||||
|
||||
// Freshness boost (exp decay, 60-day half-life, weight 0.10)
|
||||
$publishedAt = $candidate->published_at ?? $candidate->created_at ?? now();
|
||||
$ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400);
|
||||
$freshness = exp(-$ageDays / 60.0) * 0.10;
|
||||
@@ -140,20 +175,10 @@ final class SimilarArtworksController extends Controller
|
||||
usort($scored, fn ($a, $b) => $b['score'] <=> $a['score']);
|
||||
|
||||
return array_values(
|
||||
array_map(fn (array $item): array => [
|
||||
'id' => $item['artwork']->id,
|
||||
'title' => $item['artwork']->title,
|
||||
'slug' => $item['artwork']->slug,
|
||||
'thumb' => $item['artwork']->thumbUrl('md'),
|
||||
'url' => '/art/' . $item['artwork']->id . '/' . $item['artwork']->slug,
|
||||
'author' => $item['artwork']->user?->name ?? 'Artist',
|
||||
'author_avatar' => $item['artwork']->user?->profile?->avatar_url,
|
||||
'author_id' => $item['artwork']->user_id,
|
||||
'orientation' => $this->orientation($item['artwork']),
|
||||
'width' => $item['artwork']->width,
|
||||
'height' => $item['artwork']->height,
|
||||
'score' => round((float) $item['score'], 5),
|
||||
], array_slice($scored, 0, self::LIMIT))
|
||||
array_map(fn (array $item): array => array_merge(
|
||||
$this->formatArtwork($item['artwork']),
|
||||
['score' => round((float) $item['score'], 5)]
|
||||
), array_slice($scored, 0, self::LIMIT))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
188
app/Http/Controllers/Api/StoriesApiController.php
Normal file
188
app/Http/Controllers/Api/StoriesApiController.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryTag;
|
||||
use App\Models\StoryAuthor;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Stories API — JSON endpoints for React frontend.
|
||||
*
|
||||
* GET /api/stories list published stories (paginated)
|
||||
* GET /api/stories/{slug} single story detail
|
||||
* GET /api/stories/tag/{tag} stories by tag
|
||||
* GET /api/stories/author/{author} stories by author
|
||||
* GET /api/stories/featured featured stories
|
||||
*/
|
||||
final class StoriesApiController extends Controller
|
||||
{
|
||||
/**
|
||||
* List published stories (paginated).
|
||||
* GET /api/stories?page=1&per_page=12
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = min((int) $request->get('per_page', 12), 50);
|
||||
$page = (int) $request->get('page', 1);
|
||||
|
||||
$cacheKey = "stories:api:list:{$perPage}:{$page}";
|
||||
|
||||
$stories = Cache::remember($cacheKey, 300, fn () =>
|
||||
Story::published()
|
||||
->with('author', 'tags')
|
||||
->orderByDesc('published_at')
|
||||
->paginate($perPage, ['*'], 'page', $page)
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
|
||||
'meta' => [
|
||||
'current_page' => $stories->currentPage(),
|
||||
'last_page' => $stories->lastPage(),
|
||||
'per_page' => $stories->perPage(),
|
||||
'total' => $stories->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single story detail.
|
||||
* GET /api/stories/{slug}
|
||||
*/
|
||||
public function show(string $slug): JsonResponse
|
||||
{
|
||||
$story = Cache::remember('stories:api:' . $slug, 600, fn () =>
|
||||
Story::published()
|
||||
->with('author', 'tags')
|
||||
->where('slug', $slug)
|
||||
->firstOrFail()
|
||||
);
|
||||
|
||||
return response()->json($this->formatFull($story));
|
||||
}
|
||||
|
||||
/**
|
||||
* Featured story.
|
||||
* GET /api/stories/featured
|
||||
*/
|
||||
public function featured(): JsonResponse
|
||||
{
|
||||
$story = Cache::remember('stories:api:featured', 300, fn () =>
|
||||
Story::published()->featured()
|
||||
->with('author', 'tags')
|
||||
->orderByDesc('published_at')
|
||||
->first()
|
||||
);
|
||||
|
||||
if (! $story) {
|
||||
return response()->json(null);
|
||||
}
|
||||
|
||||
return response()->json($this->formatFull($story));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stories by tag.
|
||||
* GET /api/stories/tag/{tag}?page=1
|
||||
*/
|
||||
public function byTag(Request $request, string $tag): JsonResponse
|
||||
{
|
||||
$storyTag = StoryTag::where('slug', $tag)->firstOrFail();
|
||||
$page = (int) $request->get('page', 1);
|
||||
|
||||
$stories = Cache::remember("stories:api:tag:{$tag}:{$page}", 300, fn () =>
|
||||
Story::published()
|
||||
->with('author', 'tags')
|
||||
->whereHas('tags', fn ($q) => $q->where('stories_tags.id', $storyTag->id))
|
||||
->orderByDesc('published_at')
|
||||
->paginate(12, ['*'], 'page', $page)
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'tag' => ['id' => $storyTag->id, 'slug' => $storyTag->slug, 'name' => $storyTag->name],
|
||||
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
|
||||
'meta' => [
|
||||
'current_page' => $stories->currentPage(),
|
||||
'last_page' => $stories->lastPage(),
|
||||
'per_page' => $stories->perPage(),
|
||||
'total' => $stories->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stories by author.
|
||||
* GET /api/stories/author/{username}?page=1
|
||||
*/
|
||||
public function byAuthor(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$author = StoryAuthor::whereHas('user', fn ($q) => $q->where('username', $username))->first()
|
||||
?? StoryAuthor::where('name', $username)->firstOrFail();
|
||||
|
||||
$page = (int) $request->get('page', 1);
|
||||
|
||||
$stories = Cache::remember("stories:api:author:{$author->id}:{$page}", 300, fn () =>
|
||||
Story::published()
|
||||
->with('author', 'tags')
|
||||
->where('author_id', $author->id)
|
||||
->orderByDesc('published_at')
|
||||
->paginate(12, ['*'], 'page', $page)
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'author' => $this->formatAuthor($author),
|
||||
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
|
||||
'meta' => [
|
||||
'current_page' => $stories->currentPage(),
|
||||
'last_page' => $stories->lastPage(),
|
||||
'per_page' => $stories->perPage(),
|
||||
'total' => $stories->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Private formatters ────────────────────────────────────────────────
|
||||
|
||||
private function formatCard(Story $story): array
|
||||
{
|
||||
return [
|
||||
'id' => $story->id,
|
||||
'slug' => $story->slug,
|
||||
'url' => $story->url,
|
||||
'title' => $story->title,
|
||||
'excerpt' => $story->excerpt,
|
||||
'cover_image' => $story->cover_url,
|
||||
'author' => $story->author ? $this->formatAuthor($story->author) : null,
|
||||
'tags' => $story->tags->map(fn ($t) => ['id' => $t->id, 'slug' => $t->slug, 'name' => $t->name, 'url' => $t->url]),
|
||||
'views' => $story->views,
|
||||
'featured' => $story->featured,
|
||||
'reading_time' => $story->reading_time,
|
||||
'published_at' => $story->published_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatFull(Story $story): array
|
||||
{
|
||||
return array_merge($this->formatCard($story), [
|
||||
'content' => $story->content,
|
||||
]);
|
||||
}
|
||||
|
||||
private function formatAuthor(StoryAuthor $author): array
|
||||
{
|
||||
return [
|
||||
'id' => $author->id,
|
||||
'name' => $author->name,
|
||||
'avatar_url' => $author->avatar_url,
|
||||
'bio' => $author->bio,
|
||||
'profile_url' => $author->profile_url,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ final class UploadController extends Controller
|
||||
$user = $request->user();
|
||||
$sessionId = (string) $request->validated('session_id');
|
||||
$artworkId = (int) $request->validated('artwork_id');
|
||||
$originalFileName = $request->validated('file_name');
|
||||
|
||||
$session = $sessions->getOrFail($sessionId);
|
||||
|
||||
@@ -94,6 +95,14 @@ final class UploadController extends Controller
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
if ($pipeline->originalHashExists($validated->hash)) {
|
||||
return response()->json([
|
||||
'message' => 'Duplicate upload is not allowed. This file already exists.',
|
||||
'reason' => 'duplicate_hash',
|
||||
'hash' => $validated->hash,
|
||||
], Response::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
$scan = $pipeline->scan($sessionId);
|
||||
if (! $scan->ok) {
|
||||
return response()->json([
|
||||
@@ -103,13 +112,13 @@ final class UploadController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId) {
|
||||
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, $originalFileName) {
|
||||
if ((bool) config('uploads.queue_derivatives', false)) {
|
||||
GenerateDerivativesJob::dispatch($sessionId, $validated->hash, $artworkId)->afterCommit();
|
||||
GenerateDerivativesJob::dispatch($sessionId, $validated->hash, $artworkId, is_string($originalFileName) ? $originalFileName : null)->afterCommit();
|
||||
return 'queued';
|
||||
}
|
||||
|
||||
$pipeline->processAndPublish($sessionId, $validated->hash, $artworkId);
|
||||
$pipeline->processAndPublish($sessionId, $validated->hash, $artworkId, is_string($originalFileName) ? $originalFileName : null);
|
||||
|
||||
// Derivatives are available now; dispatch AI auto-tagging.
|
||||
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
|
||||
@@ -478,8 +487,35 @@ final class UploadController extends Controller
|
||||
$validated = $request->validate([
|
||||
'title' => ['nullable', 'string', 'max:150'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'category' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'tags' => ['nullable', 'array', 'max:15'],
|
||||
'tags.*' => ['string', 'max:64'],
|
||||
// Scheduled-publishing fields
|
||||
'mode' => ['nullable', 'string', 'in:now,schedule'],
|
||||
'publish_at' => ['nullable', 'string', 'date'],
|
||||
'timezone' => ['nullable', 'string', 'max:64'],
|
||||
'visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
|
||||
]);
|
||||
|
||||
$mode = $validated['mode'] ?? 'now';
|
||||
$visibility = $validated['visibility'] ?? 'public';
|
||||
|
||||
// Resolve the UTC publish_at datetime for schedule mode
|
||||
$publishAt = null;
|
||||
if ($mode === 'schedule' && ! empty($validated['publish_at'])) {
|
||||
try {
|
||||
$publishAt = \Carbon\Carbon::parse($validated['publish_at'])->utc();
|
||||
// Must be at least 1 minute in the future (server-side guard)
|
||||
if ($publishAt->lte(now()->addMinute())) {
|
||||
return response()->json([
|
||||
'message' => 'Scheduled publish time must be at least 1 minute in the future.',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Invalid publish_at datetime.'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
if (ctype_digit($id)) {
|
||||
$artworkId = (int) $id;
|
||||
$artwork = Artwork::query()->find($artworkId);
|
||||
@@ -513,11 +549,76 @@ final class UploadController extends Controller
|
||||
$artwork->description = $validated['description'];
|
||||
}
|
||||
$artwork->slug = $slug;
|
||||
$artwork->is_public = true;
|
||||
$artwork->artwork_timezone = $validated['timezone'] ?? null;
|
||||
|
||||
// Sync category if provided
|
||||
$categoryId = isset($validated['category']) ? (int) $validated['category'] : null;
|
||||
if ($categoryId && \App\Models\Category::where('id', $categoryId)->exists()) {
|
||||
$artwork->categories()->sync([$categoryId]);
|
||||
}
|
||||
|
||||
// Sync tags if provided
|
||||
if (!empty($validated['tags']) && is_array($validated['tags'])) {
|
||||
$tagIds = [];
|
||||
foreach ($validated['tags'] as $tagSlug) {
|
||||
$tag = \App\Models\Tag::firstOrCreate(
|
||||
['slug' => Str::slug($tagSlug)],
|
||||
['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0]
|
||||
);
|
||||
$tagIds[$tag->id] = ['source' => 'user', 'confidence' => 1.0];
|
||||
}
|
||||
$artwork->tags()->sync($tagIds);
|
||||
}
|
||||
|
||||
if ($mode === 'schedule' && $publishAt) {
|
||||
// Scheduled: store publish_at but don't make public yet
|
||||
$artwork->is_public = false;
|
||||
$artwork->is_approved = true;
|
||||
$artwork->publish_at = $publishAt;
|
||||
$artwork->artwork_status = 'scheduled';
|
||||
$artwork->published_at = null;
|
||||
$artwork->save();
|
||||
|
||||
try {
|
||||
$artwork->unsearchable();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to remove scheduled artwork from search index', [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'scheduled',
|
||||
'slug' => (string) $artwork->slug,
|
||||
'publish_at' => $publishAt->toISOString(),
|
||||
'published_at' => null,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
// Publish immediately
|
||||
$artwork->is_public = ($visibility !== 'private');
|
||||
$artwork->is_approved = true;
|
||||
$artwork->published_at = now();
|
||||
$artwork->artwork_status = 'published';
|
||||
$artwork->publish_at = null;
|
||||
$artwork->save();
|
||||
|
||||
try {
|
||||
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && !empty($artwork->published_at)) {
|
||||
$artwork->searchable();
|
||||
} else {
|
||||
$artwork->unsearchable();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to sync artwork search index after publish', [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Record upload activity event
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
|
||||
212
app/Http/Controllers/Api/UploadVisionSuggestController.php
Normal file
212
app/Http/Controllers/Api/UploadVisionSuggestController.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\TagNormalizer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Synchronous Vision tag suggestions for the upload wizard.
|
||||
*
|
||||
* POST /api/uploads/{id}/vision-suggest
|
||||
*
|
||||
* Calls the Vision gateway (/analyze/all) synchronously and returns
|
||||
* normalised tag suggestions immediately — without going through the queue.
|
||||
* The queue-based AutoTagArtworkJob still runs in the background and writes
|
||||
* to the DB; this endpoint gives the user instant pre-fill on Step 2.
|
||||
*/
|
||||
final class UploadVisionSuggestController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TagNormalizer $normalizer,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id, Request $request): JsonResponse
|
||||
{
|
||||
if (! (bool) config('vision.enabled', true)) {
|
||||
return response()->json(['tags' => [], 'vision_enabled' => false]);
|
||||
}
|
||||
|
||||
$artwork = Artwork::query()->findOrFail($id);
|
||||
$this->authorizeOrNotFound($request->user(), $artwork);
|
||||
|
||||
$imageUrl = $this->buildImageUrl((string) $artwork->hash);
|
||||
if ($imageUrl === null) {
|
||||
return response()->json([
|
||||
'tags' => [],
|
||||
'vision_enabled' => true,
|
||||
'reason' => 'image_url_unavailable',
|
||||
]);
|
||||
}
|
||||
|
||||
$gatewayBase = trim((string) config('vision.gateway.base_url', config('vision.clip.base_url', '')));
|
||||
if ($gatewayBase === '') {
|
||||
return response()->json([
|
||||
'tags' => [],
|
||||
'vision_enabled' => true,
|
||||
'reason' => 'gateway_not_configured',
|
||||
]);
|
||||
}
|
||||
|
||||
$url = rtrim($gatewayBase, '/') . '/analyze/all';
|
||||
$limit = min(20, max(5, (int) ($request->query('limit', 10))));
|
||||
$timeout = (int) config('vision.gateway.timeout_seconds', 10);
|
||||
$cTimeout = (int) config('vision.gateway.connect_timeout_seconds', 3);
|
||||
$ref = (string) Str::uuid();
|
||||
|
||||
try {
|
||||
/** @var \Illuminate\Http\Client\Response $response */
|
||||
$response = Http::acceptJson()
|
||||
->connectTimeout(max(1, $cTimeout))
|
||||
->timeout(max(1, $timeout))
|
||||
->withHeaders(['X-Request-ID' => $ref])
|
||||
->post($url, [
|
||||
'url' => $imageUrl,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
|
||||
if (! $response->ok()) {
|
||||
Log::warning('vision-suggest: non-ok response', [
|
||||
'ref' => $ref,
|
||||
'artwork_id' => $id,
|
||||
'status' => $response->status(),
|
||||
'body' => Str::limit($response->body(), 400),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'tags' => [],
|
||||
'vision_enabled' => true,
|
||||
'reason' => 'gateway_error_' . $response->status(),
|
||||
]);
|
||||
}
|
||||
|
||||
$tags = $this->parseGatewayResponse($response->json());
|
||||
|
||||
return response()->json([
|
||||
'tags' => $tags,
|
||||
'vision_enabled' => true,
|
||||
'source' => 'gateway_sync',
|
||||
]);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('vision-suggest: request failed', [
|
||||
'ref' => $ref,
|
||||
'artwork_id' => $id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'tags' => [],
|
||||
'vision_enabled' => true,
|
||||
'reason' => 'gateway_exception',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function buildImageUrl(string $hash): ?string
|
||||
{
|
||||
$base = rtrim((string) config('cdn.files_url', ''), '/');
|
||||
if ($base === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));
|
||||
$clean = str_pad($clean, 6, '0');
|
||||
$seg = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00'];
|
||||
$variant = (string) config('vision.image_variant', 'md');
|
||||
|
||||
return $base . '/img/' . implode('/', $seg) . '/' . $variant . '.webp';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the /analyze/all gateway response.
|
||||
*
|
||||
* The gateway returns a unified object:
|
||||
* { clip: [{tag, confidence}], blip: ["caption1"], yolo: [{tag, confidence}] }
|
||||
* or a flat list of tags directly.
|
||||
*
|
||||
* @param mixed $json
|
||||
* @return array<int, array{name: string, slug: string, confidence: float|null, source: string, is_ai: true}>
|
||||
*/
|
||||
private function parseGatewayResponse(mixed $json): array
|
||||
{
|
||||
$raw = [];
|
||||
|
||||
if (! is_array($json)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Unified gateway response
|
||||
if (isset($json['clip']) && is_array($json['clip'])) {
|
||||
foreach ($json['clip'] as $item) {
|
||||
$raw[] = ['tag' => $item['tag'] ?? $item['name'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'clip'];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($json['yolo']) && is_array($json['yolo'])) {
|
||||
foreach ($json['yolo'] as $item) {
|
||||
$raw[] = ['tag' => $item['tag'] ?? $item['label'] ?? $item['name'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'yolo'];
|
||||
}
|
||||
}
|
||||
|
||||
// Flat lists
|
||||
if ($raw === []) {
|
||||
$list = $json['tags'] ?? $json['data'] ?? $json;
|
||||
if (is_array($list)) {
|
||||
foreach ($list as $item) {
|
||||
if (is_array($item)) {
|
||||
$raw[] = ['tag' => $item['tag'] ?? $item['name'] ?? $item['label'] ?? '', 'confidence' => $item['confidence'] ?? null, 'source' => 'vision'];
|
||||
} elseif (is_string($item)) {
|
||||
$raw[] = ['tag' => $item, 'confidence' => null, 'source' => 'vision'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by slug, keep highest confidence
|
||||
$bySlug = [];
|
||||
foreach ($raw as $r) {
|
||||
$slug = $this->normalizer->normalize((string) ($r['tag'] ?? ''));
|
||||
if ($slug === '') {
|
||||
continue;
|
||||
}
|
||||
$conf = isset($r['confidence']) && is_numeric($r['confidence']) ? (float) $r['confidence'] : null;
|
||||
|
||||
if (! isset($bySlug[$slug]) || ($conf !== null && $conf > (float) ($bySlug[$slug]['confidence'] ?? 0))) {
|
||||
$bySlug[$slug] = [
|
||||
'name' => ucwords(str_replace(['-', '_'], ' ', $slug)),
|
||||
'slug' => $slug,
|
||||
'confidence' => $conf,
|
||||
'source' => $r['source'] ?? 'vision',
|
||||
'is_ai' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by confidence desc
|
||||
$sorted = array_values($bySlug);
|
||||
usort($sorted, static fn ($a, $b) => ($b['confidence'] ?? 0) <=> ($a['confidence'] ?? 0));
|
||||
|
||||
return $sorted;
|
||||
}
|
||||
|
||||
private function authorizeOrNotFound(mixed $user, Artwork $artwork): void
|
||||
{
|
||||
if (! $user) {
|
||||
abort(404);
|
||||
}
|
||||
if ((int) $artwork->user_id !== (int) $user->id) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ArtworkIndexRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Services\Recommendations\SimilarArtworksService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
@@ -97,84 +96,12 @@ class ArtworkController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$foundArtwork->loadMissing(['categories.contentType', 'user']);
|
||||
|
||||
$defaultAlgoVersion = (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1');
|
||||
$selectedAlgoVersion = $this->selectAlgoVersionForRequest($request, $defaultAlgoVersion);
|
||||
|
||||
$similarService = app(SimilarArtworksService::class);
|
||||
$similarArtworks = $similarService->forArtwork((int) $foundArtwork->id, 12, $selectedAlgoVersion);
|
||||
|
||||
if ($similarArtworks->isEmpty() && $selectedAlgoVersion !== $defaultAlgoVersion) {
|
||||
$similarArtworks = $similarService->forArtwork((int) $foundArtwork->id, 12, $defaultAlgoVersion);
|
||||
$selectedAlgoVersion = $defaultAlgoVersion;
|
||||
}
|
||||
|
||||
$similarArtworks->each(static function (Artwork $item): void {
|
||||
$item->loadMissing(['categories.contentType', 'user']);
|
||||
});
|
||||
|
||||
$similarItems = $similarArtworks
|
||||
->map(function (Artwork $item): ?array {
|
||||
$category = $item->categories->first();
|
||||
$contentType = $category?->contentType;
|
||||
|
||||
if (! $category || ! $contentType || empty($item->slug)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $item->id,
|
||||
'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'author' => html_entity_decode((string) optional($item->user)->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'thumb' => (string) ($item->thumb_url ?? $item->thumb ?? '/gfx/sb_join.jpg'),
|
||||
'thumb_srcset' => (string) ($item->thumb_srcset ?? ''),
|
||||
'url' => route('artworks.show', [
|
||||
'contentTypeSlug' => (string) $contentType->slug,
|
||||
'categoryPath' => (string) $category->slug,
|
||||
'artwork' => (string) $item->slug,
|
||||
]),
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
return view('artworks.show', [
|
||||
'artwork' => $foundArtwork,
|
||||
'similarItems' => $similarItems,
|
||||
'similarAlgoVersion' => $selectedAlgoVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
private function selectAlgoVersionForRequest(Request $request, string $default): string
|
||||
{
|
||||
$configured = (array) config('recommendations.ab.algo_versions', []);
|
||||
$versions = array_values(array_filter(array_map(static fn ($value): string => trim((string) $value), $configured)));
|
||||
|
||||
if ($versions === []) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (! in_array($default, $versions, true)) {
|
||||
array_unshift($versions, $default);
|
||||
$versions = array_values(array_unique($versions));
|
||||
}
|
||||
|
||||
$forced = trim((string) $request->query('algo_version', ''));
|
||||
if ($forced !== '' && in_array($forced, $versions, true)) {
|
||||
return $forced;
|
||||
}
|
||||
|
||||
if (count($versions) === 1) {
|
||||
return $versions[0];
|
||||
}
|
||||
|
||||
$visitorKey = $request->user()?->id
|
||||
? 'u:' . (string) $request->user()->id
|
||||
: 's:' . (string) $request->session()->getId();
|
||||
|
||||
$bucket = abs(crc32($visitorKey)) % count($versions);
|
||||
|
||||
return $versions[$bucket] ?? $default;
|
||||
// Delegate to the canonical ArtworkPageController which builds all
|
||||
// required view data ($meta, thumbnails, related items, comments, etc.)
|
||||
return app(\App\Http\Controllers\Web\ArtworkPageController::class)->show(
|
||||
$request,
|
||||
(int) $foundArtwork->id,
|
||||
$foundArtwork->slug,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
252
app/Http/Controllers/Auth/OAuthController.php
Normal file
252
app/Http/Controllers/Auth/OAuthController.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Contracts\User as SocialiteUser;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Throwable;
|
||||
|
||||
class OAuthController extends Controller
|
||||
{
|
||||
/** Providers enabled for OAuth login. */
|
||||
private const ALLOWED_PROVIDERS = ['google', 'discord'];
|
||||
|
||||
/**
|
||||
* Redirect the user to the provider's OAuth page.
|
||||
*/
|
||||
public function redirectToProvider(string $provider): RedirectResponse
|
||||
{
|
||||
$this->abortIfInvalidProvider($provider);
|
||||
|
||||
return Socialite::driver($provider)->redirect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the provider callback and authenticate the user.
|
||||
*/
|
||||
public function handleProviderCallback(string $provider): RedirectResponse
|
||||
{
|
||||
$this->abortIfInvalidProvider($provider);
|
||||
|
||||
try {
|
||||
/** @var SocialiteUser $socialUser */
|
||||
$socialUser = Socialite::driver($provider)->user();
|
||||
} catch (Throwable) {
|
||||
return redirect()->route('login')
|
||||
->withErrors(['oauth' => 'Authentication failed. Please try again.']);
|
||||
}
|
||||
|
||||
$providerId = (string) $socialUser->getId();
|
||||
$providerEmail = $this->resolveEmail($socialUser);
|
||||
$verified = $this->isEmailVerifiedByProvider($provider, $socialUser);
|
||||
|
||||
// ── 1. Provider account already linked → login ───────────────────────
|
||||
$existing = SocialAccount::query()
|
||||
->where('provider', $provider)
|
||||
->where('provider_id', $providerId)
|
||||
->with('user')
|
||||
->first();
|
||||
|
||||
if ($existing !== null && $existing->user !== null) {
|
||||
return $this->loginAndRedirect($existing->user);
|
||||
}
|
||||
|
||||
// ── 2. Email match → link to existing account ────────────────────────
|
||||
// Covers both verified and unverified users: if the OAuth provider
|
||||
// has confirmed this email we can safely link it and mark it verified,
|
||||
// preventing a duplicate-email insert when the user had started
|
||||
// registration via email but never finished verification.
|
||||
if ($providerEmail !== null && $verified) {
|
||||
$userByEmail = User::query()
|
||||
->where('email', strtolower($providerEmail))
|
||||
->first();
|
||||
|
||||
if ($userByEmail !== null) {
|
||||
// If their email was not yet verified, promote it now — the
|
||||
// OAuth provider has already verified it on our behalf.
|
||||
if ($userByEmail->email_verified_at === null) {
|
||||
$userByEmail->forceFill([
|
||||
'email_verified_at' => now(),
|
||||
'is_active' => true,
|
||||
// Keep their onboarding step unless already complete
|
||||
'onboarding_step' => $userByEmail->onboarding_step === 'email'
|
||||
? 'username'
|
||||
: ($userByEmail->onboarding_step ?? 'username'),
|
||||
])->save();
|
||||
}
|
||||
|
||||
$this->createSocialAccount($userByEmail, $provider, $providerId, $providerEmail, $socialUser->getAvatar());
|
||||
|
||||
return $this->loginAndRedirect($userByEmail);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Provider email not verified → reject auto-link ────────────────
|
||||
if ($providerEmail !== null && ! $verified) {
|
||||
return redirect()->route('login')
|
||||
->withErrors(['oauth' => 'Your ' . ucfirst($provider) . ' email is not verified. Please verify it and try again.']);
|
||||
}
|
||||
|
||||
// ── 4. No email at all → cannot proceed ──────────────────────────────
|
||||
if ($providerEmail === null) {
|
||||
return redirect()->route('login')
|
||||
->withErrors(['oauth' => 'Could not retrieve your email address from ' . ucfirst($provider) . '. Please register with email.']);
|
||||
}
|
||||
|
||||
// ── 5. New user creation ──────────────────────────────────────────────
|
||||
try {
|
||||
$user = $this->createOAuthUser($socialUser, $provider, $providerId, $providerEmail);
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
// Race condition: another request inserted the same email between
|
||||
// the lookup above and this insert. Fetch and link instead.
|
||||
$user = User::query()->where('email', strtolower($providerEmail))->first();
|
||||
|
||||
if ($user === null) {
|
||||
return redirect()->route('login')
|
||||
->withErrors(['oauth' => 'An error occurred during sign in. Please try again.']);
|
||||
}
|
||||
|
||||
$this->createSocialAccount($user, $provider, $providerId, $providerEmail, $socialUser->getAvatar());
|
||||
}
|
||||
|
||||
return $this->loginAndRedirect($user);
|
||||
}
|
||||
|
||||
// ─── Private helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private function abortIfInvalidProvider(string $provider): void
|
||||
{
|
||||
abort_unless(in_array($provider, self::ALLOWED_PROVIDERS, true), 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create social_accounts row linked to a user.
|
||||
*/
|
||||
private function createSocialAccount(
|
||||
User $user,
|
||||
string $provider,
|
||||
string $providerId,
|
||||
?string $providerEmail,
|
||||
?string $avatar
|
||||
): void {
|
||||
SocialAccount::query()->updateOrCreate(
|
||||
['provider' => $provider, 'provider_id' => $providerId],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'provider_email' => $providerEmail !== null ? strtolower($providerEmail) : null,
|
||||
'avatar' => $avatar,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a brand-new user from OAuth data.
|
||||
*/
|
||||
private function createOAuthUser(
|
||||
SocialiteUser $socialUser,
|
||||
string $provider,
|
||||
string $providerId,
|
||||
string $providerEmail
|
||||
): User {
|
||||
$user = DB::transaction(function () use ($socialUser, $provider, $providerId, $providerEmail): User {
|
||||
$name = $this->resolveDisplayName($socialUser, $providerEmail);
|
||||
|
||||
$user = User::query()->create([
|
||||
'username' => null,
|
||||
'name' => $name,
|
||||
'email' => strtolower($providerEmail),
|
||||
'email_verified_at' => now(),
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
'is_active' => true,
|
||||
'onboarding_step' => 'username',
|
||||
'username_changed_at' => now(),
|
||||
]);
|
||||
|
||||
$this->createSocialAccount(
|
||||
$user,
|
||||
$provider,
|
||||
$providerId,
|
||||
$providerEmail,
|
||||
$socialUser->getAvatar()
|
||||
);
|
||||
|
||||
return $user;
|
||||
});
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login the user and redirect appropriately.
|
||||
*/
|
||||
private function loginAndRedirect(User $user): RedirectResponse
|
||||
{
|
||||
Auth::login($user, remember: true);
|
||||
|
||||
request()->session()->regenerate();
|
||||
|
||||
$step = strtolower((string) ($user->onboarding_step ?? ''));
|
||||
|
||||
if (in_array($step, ['username', 'password'], true)) {
|
||||
return redirect()->route('setup.username.create');
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a usable display name from the social user.
|
||||
*/
|
||||
private function resolveDisplayName(SocialiteUser $socialUser, string $email): string
|
||||
{
|
||||
$name = trim((string) ($socialUser->getName() ?? ''));
|
||||
|
||||
if ($name !== '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return Str::before($email, '@');
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort email resolution. Apple can return null email on repeat logins.
|
||||
*/
|
||||
private function resolveEmail(SocialiteUser $socialUser): ?string
|
||||
{
|
||||
$email = $socialUser->getEmail();
|
||||
|
||||
if ($email === null || $email === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strtolower(trim($email));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the provider has verified the user's email.
|
||||
*
|
||||
* - Google: returns email_verified flag in raw data
|
||||
* - Discord: returns verified flag in raw data
|
||||
* - Apple: only issues tokens for verified Apple IDs
|
||||
*/
|
||||
private function isEmailVerifiedByProvider(string $provider, SocialiteUser $socialUser): bool
|
||||
{
|
||||
$raw = (array) ($socialUser->getRaw() ?? []);
|
||||
|
||||
return match ($provider) {
|
||||
'google' => filter_var($raw['email_verified'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
'discord' => (bool) ($raw['verified'] ?? false),
|
||||
'apple' => true, // Apple only issues tokens for verified Apple IDs
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\View\View;
|
||||
use App\Support\AvatarUrl;
|
||||
|
||||
class FavoriteController extends Controller
|
||||
{
|
||||
@@ -36,16 +37,41 @@ class FavoriteController extends Controller
|
||||
|
||||
$artworks = collect();
|
||||
if ($slice !== []) {
|
||||
$arts = Artwork::query()->whereIn('id', $slice)->with('user')->get()->keyBy('id');
|
||||
$arts = Artwork::query()
|
||||
->whereIn('id', $slice)
|
||||
->with(['user.profile', 'categories'])
|
||||
->withCount(['favourites', 'comments'])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($slice as $id) {
|
||||
$a = $arts->get($id);
|
||||
if (! $a) continue;
|
||||
|
||||
$primaryCategory = $a->categories->sortBy('sort_order')->first();
|
||||
$username = $a->user?->username ?? $a->user?->name ?? '';
|
||||
|
||||
$artworks->push((object) [
|
||||
'id' => $a->id,
|
||||
'name' => $a->title,
|
||||
'title' => $a->title,
|
||||
'thumb' => $a->thumbUrl('md') ?? $a->thumbnail_url ?? null,
|
||||
'thumb_url' => $a->thumbUrl('md') ?? $a->thumbnail_url ?? null,
|
||||
'slug' => $a->slug,
|
||||
'author' => $a->user?->username ?? $a->user?->name,
|
||||
'author' => $username,
|
||||
'uname' => $username,
|
||||
'username' => $a->user?->username ?? '',
|
||||
'avatar_url' => AvatarUrl::forUser(
|
||||
(int) ($a->user_id ?? 0),
|
||||
$a->user?->profile?->avatar_hash ?? null,
|
||||
64
|
||||
),
|
||||
'category_name' => $primaryCategory->name ?? '',
|
||||
'category_slug' => $primaryCategory->slug ?? '',
|
||||
'width' => $a->width,
|
||||
'height' => $a->height,
|
||||
'likes' => (int) ($a->favourites_count ?? $a->likes ?? 0),
|
||||
'comments_count' => (int) ($a->comments_count ?? 0),
|
||||
'published_at' => $a->published_at,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -34,8 +34,9 @@ class FollowingController extends Controller
|
||||
->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, 50),
|
||||
'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,
|
||||
|
||||
@@ -68,6 +68,7 @@ class ForumController extends Controller
|
||||
'last_update' => $item->last_post_at ?? $item->created_at,
|
||||
'uname' => $item->user?->name,
|
||||
'num_posts' => (int) ($item->posts_count ?? 0),
|
||||
'is_pinned' => (bool) $item->is_pinned,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class LatestCommentsController extends Controller
|
||||
$user = $c->user;
|
||||
|
||||
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? ($present['url']) : '/gfx/sb_join.jpg';
|
||||
$thumb = $present ? ($present['url']) : 'https://files.skinbase.org/default/missing_md.webp';
|
||||
|
||||
return (object) [
|
||||
'comment_id' => $c->getKey(),
|
||||
|
||||
@@ -43,7 +43,7 @@ class TodayDownloadsController extends Controller
|
||||
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$encoded = null; // legacy encoding unavailable; leave null
|
||||
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? $present['url'] : '/gfx/sb_join.jpg';
|
||||
$thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$categoryId = $art->categories->first()->id ?? null;
|
||||
|
||||
return (object) [
|
||||
|
||||
@@ -36,7 +36,8 @@ class TopAuthorsController extends Controller
|
||||
$query = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
|
||||
->mergeBindings($sub->getQuery())
|
||||
->join('users as u', 'u.id', '=', 't.user_id')
|
||||
->select('u.id as user_id', 'u.name as uname', 'u.username', 't.total_metric', 't.latest_published')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->select('u.id as user_id', 'u.name as uname', 'u.username', 'up.avatar_hash', 't.total_metric', 't.latest_published')
|
||||
->orderByDesc('t.total_metric')
|
||||
->orderByDesc('t.latest_published');
|
||||
|
||||
@@ -48,6 +49,7 @@ class TopAuthorsController extends Controller
|
||||
'user_id' => $row->user_id,
|
||||
'uname' => $row->uname,
|
||||
'username' => $row->username,
|
||||
'avatar_hash' => $row->avatar_hash,
|
||||
'total' => (int) $row->total_metric,
|
||||
'metric' => $metric,
|
||||
];
|
||||
|
||||
@@ -58,7 +58,7 @@ class LegacyController extends Controller
|
||||
(object) [
|
||||
'id' => 0,
|
||||
'name' => 'Sample Artwork',
|
||||
'picture' => 'gfx/sb_join.jpg',
|
||||
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'category' => null,
|
||||
'datum' => now(),
|
||||
'category_name' => 'Photography',
|
||||
@@ -289,7 +289,7 @@ class LegacyController extends Controller
|
||||
$featured = (object) [
|
||||
'id' => 0,
|
||||
'name' => 'Featured Artwork',
|
||||
'picture' => '/gfx/sb_join.jpg',
|
||||
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'uname' => 'Skinbase',
|
||||
];
|
||||
}
|
||||
@@ -298,7 +298,7 @@ class LegacyController extends Controller
|
||||
$memberFeatured = (object) [
|
||||
'id' => 0,
|
||||
'name' => 'Members Pick',
|
||||
'picture' => '/gfx/sb_join.jpg',
|
||||
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'uname' => 'Skinbase',
|
||||
'votes' => 0,
|
||||
];
|
||||
@@ -430,7 +430,7 @@ class LegacyController extends Controller
|
||||
[
|
||||
'id' => 1,
|
||||
'name' => 'Sample Artwork',
|
||||
'picture' => 'gfx/sb_join.jpg',
|
||||
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'uname' => 'Skinbase',
|
||||
'category_name' => 'Photography',
|
||||
],
|
||||
|
||||
40
app/Http/Controllers/RSS/BlogFeedController.php
Normal file
40
app/Http/Controllers/RSS/BlogFeedController.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\RSS;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BlogPost;
|
||||
use App\Services\RSS\RSSFeedBuilder;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* BlogFeedController
|
||||
*
|
||||
* GET /rss/blog → latest blog posts feed (spec §3.6)
|
||||
*/
|
||||
final class BlogFeedController extends Controller
|
||||
{
|
||||
public function __construct(private readonly RSSFeedBuilder $builder) {}
|
||||
|
||||
public function __invoke(): Response
|
||||
{
|
||||
$feedUrl = url('/rss/blog');
|
||||
$posts = Cache::remember('rss:blog', 600, fn () =>
|
||||
BlogPost::published()
|
||||
->with('author:id,username')
|
||||
->latest('published_at')
|
||||
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||
->get()
|
||||
);
|
||||
|
||||
return $this->builder->buildFromBlogPosts(
|
||||
'Blog',
|
||||
'Latest posts from the Skinbase blog.',
|
||||
$feedUrl,
|
||||
$posts,
|
||||
);
|
||||
}
|
||||
}
|
||||
49
app/Http/Controllers/RSS/CreatorFeedController.php
Normal file
49
app/Http/Controllers/RSS/CreatorFeedController.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\RSS;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Services\RSS\RSSFeedBuilder;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* CreatorFeedController
|
||||
*
|
||||
* GET /rss/creator/{username} → latest artworks by a given creator (spec §3.5)
|
||||
*/
|
||||
final class CreatorFeedController extends Controller
|
||||
{
|
||||
public function __construct(private readonly RSSFeedBuilder $builder) {}
|
||||
|
||||
public function __invoke(string $username): Response
|
||||
{
|
||||
$user = User::where('username', $username)->first();
|
||||
|
||||
if (! $user) {
|
||||
throw new NotFoundHttpException("Creator [{$username}] not found.");
|
||||
}
|
||||
|
||||
$feedUrl = url('/rss/creator/' . $username);
|
||||
$artworks = Cache::remember('rss:creator:' . strtolower($username), 300, fn () =>
|
||||
Artwork::public()->published()
|
||||
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
|
||||
->where('artworks.user_id', $user->id)
|
||||
->latest('artworks.published_at')
|
||||
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||
->get()
|
||||
);
|
||||
|
||||
return $this->builder->buildFromArtworks(
|
||||
$user->username . '\'s Artworks',
|
||||
'Latest artworks by ' . $user->username . ' on Skinbase.',
|
||||
$feedUrl,
|
||||
$artworks,
|
||||
);
|
||||
}
|
||||
}
|
||||
98
app/Http/Controllers/RSS/DiscoverFeedController.php
Normal file
98
app/Http/Controllers/RSS/DiscoverFeedController.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\RSS;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\RSS\RSSFeedBuilder;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* DiscoverFeedController
|
||||
*
|
||||
* Powers the /rss/discover/* feeds (spec §3.2).
|
||||
*
|
||||
* GET /rss/discover → fresh/latest (default)
|
||||
* GET /rss/discover/trending → trending by trending_score_7d
|
||||
* GET /rss/discover/fresh → latest published
|
||||
* GET /rss/discover/rising → rising by heat_score
|
||||
*/
|
||||
final class DiscoverFeedController extends Controller
|
||||
{
|
||||
public function __construct(private readonly RSSFeedBuilder $builder) {}
|
||||
|
||||
/** /rss/discover → redirect to fresh */
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->fresh();
|
||||
}
|
||||
|
||||
/** /rss/discover/trending */
|
||||
public function trending(): Response
|
||||
{
|
||||
$feedUrl = url('/rss/discover/trending');
|
||||
$artworks = Cache::remember('rss:discover:trending', 600, fn () =>
|
||||
Artwork::public()->published()
|
||||
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->orderByDesc('artwork_stats.trending_score_7d')
|
||||
->orderByDesc('artworks.published_at')
|
||||
->select('artworks.*')
|
||||
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||
->get()
|
||||
);
|
||||
|
||||
return $this->builder->buildFromArtworks(
|
||||
'Trending Artworks',
|
||||
'The most-viewed and trending artworks on Skinbase over the past 7 days.',
|
||||
$feedUrl,
|
||||
$artworks,
|
||||
);
|
||||
}
|
||||
|
||||
/** /rss/discover/fresh */
|
||||
public function fresh(): Response
|
||||
{
|
||||
$feedUrl = url('/rss/discover/fresh');
|
||||
$artworks = Cache::remember('rss:discover:fresh', 300, fn () =>
|
||||
Artwork::public()->published()
|
||||
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
|
||||
->latest('published_at')
|
||||
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||
->get()
|
||||
);
|
||||
|
||||
return $this->builder->buildFromArtworks(
|
||||
'Fresh Uploads',
|
||||
'The latest artworks just published on Skinbase.',
|
||||
$feedUrl,
|
||||
$artworks,
|
||||
);
|
||||
}
|
||||
|
||||
/** /rss/discover/rising */
|
||||
public function rising(): Response
|
||||
{
|
||||
$feedUrl = url('/rss/discover/rising');
|
||||
$artworks = Cache::remember('rss:discover:rising', 600, fn () =>
|
||||
Artwork::public()->published()
|
||||
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->orderByDesc('artwork_stats.heat_score')
|
||||
->orderByDesc('artworks.published_at')
|
||||
->select('artworks.*')
|
||||
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||
->get()
|
||||
);
|
||||
|
||||
return $this->builder->buildFromArtworks(
|
||||
'Rising Artworks',
|
||||
'Fastest-growing artworks gaining momentum on Skinbase right now.',
|
||||
$feedUrl,
|
||||
$artworks,
|
||||
);
|
||||
}
|
||||
}
|
||||
105
app/Http/Controllers/RSS/ExploreFeedController.php
Normal file
105
app/Http/Controllers/RSS/ExploreFeedController.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\RSS;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ContentType;
|
||||
use App\Services\RSS\RSSFeedBuilder;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* ExploreFeedController
|
||||
*
|
||||
* Powers the /rss/explore/* feeds (spec §3.3).
|
||||
*
|
||||
* GET /rss/explore/{type} → latest by content type
|
||||
* GET /rss/explore/{type}/{mode} → sorted by mode (trending|latest|best)
|
||||
*
|
||||
* Valid types: artworks | wallpapers | skins | photography | other
|
||||
* Valid modes: trending | latest | best
|
||||
*/
|
||||
final class ExploreFeedController extends Controller
|
||||
{
|
||||
private const SORT_TTL = [
|
||||
'trending' => 600,
|
||||
'best' => 600,
|
||||
'latest' => 300,
|
||||
];
|
||||
|
||||
public function __construct(private readonly RSSFeedBuilder $builder) {}
|
||||
|
||||
/** /rss/explore/{type} — defaults to latest */
|
||||
public function byType(string $type): Response
|
||||
{
|
||||
return $this->feed($type, 'latest');
|
||||
}
|
||||
|
||||
/** /rss/explore/{type}/{mode} */
|
||||
public function byTypeMode(string $type, string $mode): Response
|
||||
{
|
||||
return $this->feed($type, $mode);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function feed(string $type, string $mode): Response
|
||||
{
|
||||
$mode = in_array($mode, ['trending', 'latest', 'best'], true) ? $mode : 'latest';
|
||||
$ttl = self::SORT_TTL[$mode] ?? 300;
|
||||
$feedUrl = url('/rss/explore/' . $type . ($mode !== 'latest' ? '/' . $mode : ''));
|
||||
$label = ucfirst(str_replace('-', ' ', $type));
|
||||
|
||||
$artworks = Cache::remember("rss:explore:{$type}:{$mode}", $ttl, function () use ($type, $mode) {
|
||||
$contentType = ContentType::where('slug', $type)->first();
|
||||
|
||||
$query = Artwork::public()->published()
|
||||
->with(['user:id,username', 'categories:id,name,slug,content_type_id']);
|
||||
|
||||
if ($contentType) {
|
||||
$query->whereHas('categories', fn ($q) =>
|
||||
$q->where('content_type_id', $contentType->id)
|
||||
);
|
||||
}
|
||||
|
||||
return match ($mode) {
|
||||
'trending' => $query
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->orderByDesc('artwork_stats.trending_score_7d')
|
||||
->orderByDesc('artworks.published_at')
|
||||
->select('artworks.*')
|
||||
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||
->get(),
|
||||
|
||||
'best' => $query
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->orderByDesc('artwork_stats.favorites')
|
||||
->orderByDesc('artwork_stats.downloads')
|
||||
->select('artworks.*')
|
||||
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||
->get(),
|
||||
|
||||
default => $query
|
||||
->latest('artworks.published_at')
|
||||
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||
->get(),
|
||||
};
|
||||
});
|
||||
|
||||
$modeLabel = match ($mode) {
|
||||
'trending' => 'Trending',
|
||||
'best' => 'Best',
|
||||
default => 'Latest',
|
||||
};
|
||||
|
||||
return $this->builder->buildFromArtworks(
|
||||
"{$modeLabel} {$label}",
|
||||
"{$modeLabel} {$label} artworks on Skinbase.",
|
||||
$feedUrl,
|
||||
$artworks,
|
||||
);
|
||||
}
|
||||
}
|
||||
40
app/Http/Controllers/RSS/GlobalFeedController.php
Normal file
40
app/Http/Controllers/RSS/GlobalFeedController.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\RSS;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\RSS\RSSFeedBuilder;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* GlobalFeedController
|
||||
*
|
||||
* GET /rss → global latest-artworks feed (spec §3.1)
|
||||
*/
|
||||
final class GlobalFeedController extends Controller
|
||||
{
|
||||
public function __construct(private readonly RSSFeedBuilder $builder) {}
|
||||
|
||||
public function __invoke(): Response
|
||||
{
|
||||
$feedUrl = url('/rss');
|
||||
$artworks = Cache::remember('rss:global', 300, fn () =>
|
||||
Artwork::public()->published()
|
||||
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
|
||||
->latest('published_at')
|
||||
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||
->get()
|
||||
);
|
||||
|
||||
return $this->builder->buildFromArtworks(
|
||||
'Latest Artworks',
|
||||
'The newest artworks published on Skinbase.',
|
||||
$feedUrl,
|
||||
$artworks,
|
||||
);
|
||||
}
|
||||
}
|
||||
49
app/Http/Controllers/RSS/TagFeedController.php
Normal file
49
app/Http/Controllers/RSS/TagFeedController.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\RSS;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Services\RSS\RSSFeedBuilder;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* TagFeedController
|
||||
*
|
||||
* GET /rss/tag/{slug} → artworks tagged with given slug (spec §3.4)
|
||||
*/
|
||||
final class TagFeedController extends Controller
|
||||
{
|
||||
public function __construct(private readonly RSSFeedBuilder $builder) {}
|
||||
|
||||
public function __invoke(string $slug): Response
|
||||
{
|
||||
$tag = Tag::where('slug', $slug)->first();
|
||||
|
||||
if (! $tag) {
|
||||
throw new NotFoundHttpException("Tag [{$slug}] not found.");
|
||||
}
|
||||
|
||||
$feedUrl = url('/rss/tag/' . $slug);
|
||||
$artworks = Cache::remember('rss:tag:' . $slug, 600, fn () =>
|
||||
Artwork::public()->published()
|
||||
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
|
||||
->whereHas('tags', fn ($q) => $q->where('tags.id', $tag->id))
|
||||
->latest('artworks.published_at')
|
||||
->limit(RSSFeedBuilder::FEED_LIMIT)
|
||||
->get()
|
||||
);
|
||||
|
||||
return $this->builder->buildFromArtworks(
|
||||
ucwords(str_replace('-', ' ', $slug)) . ' Artworks',
|
||||
'Latest Skinbase artworks tagged "' . $tag->name . '".',
|
||||
$feedUrl,
|
||||
$artworks,
|
||||
);
|
||||
}
|
||||
}
|
||||
497
app/Http/Controllers/Studio/StudioArtworksApiController.php
Normal file
497
app/Http/Controllers/Studio/StudioArtworksApiController.php
Normal file
@@ -0,0 +1,497 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ArtworkVersion;
|
||||
use App\Services\ArtworkSearchIndexer;
|
||||
use App\Services\ArtworkVersioningService;
|
||||
use App\Services\Studio\StudioArtworkQueryService;
|
||||
use App\Services\Studio\StudioBulkActionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
|
||||
/**
|
||||
* JSON API endpoints for the Studio artwork manager.
|
||||
*/
|
||||
final class StudioArtworksApiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StudioArtworkQueryService $queryService,
|
||||
private readonly StudioBulkActionService $bulkService,
|
||||
private readonly ArtworkVersioningService $versioningService,
|
||||
private readonly ArtworkSearchIndexer $searchIndexer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* GET /api/studio/artworks
|
||||
* List artworks with search, filter, sort, pagination.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
|
||||
$filters = $request->only([
|
||||
'q', 'status', 'category', 'tags', 'date_from', 'date_to',
|
||||
'performance', 'sort',
|
||||
]);
|
||||
|
||||
$perPage = (int) $request->get('per_page', 24);
|
||||
$perPage = min(max($perPage, 12), 100);
|
||||
|
||||
$paginator = $this->queryService->list($userId, $filters, $perPage);
|
||||
|
||||
// Transform the paginator items to a clean DTO
|
||||
$items = collect($paginator->items())->map(fn ($artwork) => $this->transformArtwork($artwork));
|
||||
|
||||
return response()->json([
|
||||
'data' => $items,
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/studio/artworks/bulk
|
||||
* Execute bulk operations.
|
||||
*/
|
||||
public function bulk(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'action' => 'required|string|in:publish,unpublish,archive,unarchive,delete,change_category,add_tags,remove_tags',
|
||||
'artwork_ids' => 'required|array|min:1|max:200',
|
||||
'artwork_ids.*' => 'integer',
|
||||
'params' => 'sometimes|array',
|
||||
'params.category_id' => 'sometimes|integer|exists:categories,id',
|
||||
'params.tag_ids' => 'sometimes|array',
|
||||
'params.tag_ids.*' => 'integer|exists:tags,id',
|
||||
'confirm' => 'required_if:action,delete|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['errors' => $validator->errors()], 422);
|
||||
}
|
||||
|
||||
$data = $validator->validated();
|
||||
|
||||
// Require explicit DELETE confirmation
|
||||
if ($data['action'] === 'delete' && ($data['confirm'] ?? '') !== 'DELETE') {
|
||||
return response()->json([
|
||||
'errors' => ['confirm' => ['You must type DELETE to confirm permanent deletion.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$result = $this->bulkService->execute(
|
||||
$request->user()->id,
|
||||
$data['action'],
|
||||
$data['artwork_ids'],
|
||||
$data['params'] ?? [],
|
||||
);
|
||||
|
||||
$statusCode = $result['failed'] > 0 && $result['success'] === 0 ? 422 : 200;
|
||||
|
||||
return response()->json($result, $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/studio/artworks/{id}
|
||||
* Update artwork details (title, description, visibility).
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'sometimes|string|max:255',
|
||||
'description' => 'sometimes|nullable|string|max:5000',
|
||||
'is_public' => 'sometimes|boolean',
|
||||
'category_id' => 'sometimes|nullable|integer|exists:categories,id',
|
||||
'tags' => 'sometimes|array|max:15',
|
||||
'tags.*' => 'string|max:64',
|
||||
]);
|
||||
|
||||
if (isset($validated['is_public'])) {
|
||||
if ($validated['is_public'] && !$artwork->is_public) {
|
||||
$validated['published_at'] = $artwork->published_at ?? now();
|
||||
}
|
||||
}
|
||||
|
||||
// Extract tags and category before updating core fields
|
||||
$tags = $validated['tags'] ?? null;
|
||||
$categoryId = $validated['category_id'] ?? null;
|
||||
unset($validated['tags'], $validated['category_id']);
|
||||
|
||||
$artwork->update($validated);
|
||||
|
||||
// Sync category
|
||||
if ($categoryId !== null) {
|
||||
$artwork->categories()->sync([(int) $categoryId]);
|
||||
}
|
||||
|
||||
// Sync tags (by slug/name)
|
||||
if ($tags !== null) {
|
||||
$tagIds = [];
|
||||
foreach ($tags as $tagSlug) {
|
||||
$tag = \App\Models\Tag::firstOrCreate(
|
||||
['slug' => \Illuminate\Support\Str::slug($tagSlug)],
|
||||
['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0]
|
||||
);
|
||||
$tagIds[$tag->id] = ['source' => 'studio_edit', 'confidence' => 1.0];
|
||||
}
|
||||
$artwork->tags()->sync($tagIds);
|
||||
}
|
||||
|
||||
// Reindex in Meilisearch
|
||||
try {
|
||||
$artwork->searchable();
|
||||
} catch (\Throwable $e) {
|
||||
// Meilisearch may be unavailable
|
||||
}
|
||||
|
||||
// Reload relationships for response
|
||||
$artwork->load(['categories.contentType', 'tags']);
|
||||
$primaryCategory = $artwork->categories->first();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'description' => $artwork->description,
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'slug' => $artwork->slug,
|
||||
'content_type_id' => $primaryCategory?->contentType?->id,
|
||||
'category_id' => $primaryCategory?->id,
|
||||
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/studio/artworks/{id}/toggle
|
||||
* Toggle publish/unpublish/archive for a single artwork.
|
||||
*/
|
||||
public function toggle(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'action' => 'required|string|in:publish,unpublish,archive,unarchive',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['errors' => $validator->errors()], 422);
|
||||
}
|
||||
|
||||
$result = $this->bulkService->execute(
|
||||
$request->user()->id,
|
||||
$validator->validated()['action'],
|
||||
[$id],
|
||||
);
|
||||
|
||||
if ($result['success'] === 0) {
|
||||
return response()->json(['error' => 'Action failed', 'details' => $result['errors']], 404);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/studio/artworks/{id}/analytics
|
||||
* Analytics data for a single artwork.
|
||||
*/
|
||||
public function analytics(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()
|
||||
->with(['stats', 'awardStat'])
|
||||
->findOrFail($id);
|
||||
|
||||
$stats = $artwork->stats;
|
||||
|
||||
return response()->json([
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
],
|
||||
'analytics' => [
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'favourites' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($stats?->heat_score ?? 0),
|
||||
'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function transformArtwork($artwork): array
|
||||
{
|
||||
$stats = $artwork->stats ?? null;
|
||||
|
||||
return [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb_url' => $artwork->thumbUrl('md') ?? '/images/placeholder.jpg',
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'is_approved' => (bool) $artwork->is_approved,
|
||||
'published_at' => $artwork->published_at?->toIso8601String(),
|
||||
'created_at' => $artwork->created_at?->toIso8601String(),
|
||||
'deleted_at' => $artwork->deleted_at?->toIso8601String(),
|
||||
'category' => $artwork->categories->first()?->name,
|
||||
'category_slug' => $artwork->categories->first()?->slug,
|
||||
'tags' => $artwork->tags->pluck('slug')->values()->all(),
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'favourites' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($stats?->heat_score ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/studio/tags/search?q=...
|
||||
* Search active tags by name for the bulk tag picker.
|
||||
*/
|
||||
public function searchTags(Request $request): JsonResponse
|
||||
{
|
||||
$query = trim((string) $request->input('q'));
|
||||
|
||||
$tags = \App\Models\Tag::query()
|
||||
->where('is_active', true)
|
||||
->when($query !== '', fn ($q) => $q->where('name', 'LIKE', "%{$query}%"))
|
||||
->orderByDesc('usage_count')
|
||||
->limit(30)
|
||||
->get(['id', 'name', 'slug', 'usage_count']);
|
||||
|
||||
return response()->json($tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/studio/artworks/{id}/replace-file
|
||||
* Replace the artwork's primary image file — creates a new immutable version.
|
||||
*
|
||||
* Accepts an optional `change_note` text field alongside the file.
|
||||
*/
|
||||
public function replaceFile(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB
|
||||
'change_note' => 'sometimes|nullable|string|max:500',
|
||||
]);
|
||||
|
||||
// ── Rate-limit gate (before expensive file processing) ────────────
|
||||
try {
|
||||
$this->versioningService->rateLimitCheck($request->user()->id, $artwork->id);
|
||||
} catch (TooManyRequestsHttpException $e) {
|
||||
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
|
||||
}
|
||||
|
||||
$file = $request->file('file');
|
||||
$tempPath = $file->getRealPath();
|
||||
$hash = hash_file('sha256', $tempPath);
|
||||
|
||||
// Reject identical files early (before any disk writes)
|
||||
if ($artwork->hash === $hash) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'The uploaded file is identical to the current version.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
|
||||
$storage = app(\App\Services\Uploads\UploadStorageService::class);
|
||||
$artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class);
|
||||
|
||||
// 1. Store original on disk (preserve extension when possible)
|
||||
$originalPath = $derivatives->storeOriginal($tempPath, $hash);
|
||||
$origFilename = basename($originalPath);
|
||||
$originalRelative = $storage->sectionRelativePath('original', $hash, $origFilename);
|
||||
$origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream';
|
||||
$artworkFiles->upsert($artwork->id, 'orig', $originalRelative, $origMime, (int) filesize($originalPath));
|
||||
|
||||
// 2. Generate thumbnails (xs/sm/md/lg/xl)
|
||||
$publicAbsolute = $derivatives->generatePublicDerivatives($tempPath, $hash);
|
||||
foreach ($publicAbsolute as $variant => $absolutePath) {
|
||||
$relativePath = $storage->sectionRelativePath($variant, $hash, $hash . '.webp');
|
||||
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath));
|
||||
}
|
||||
|
||||
// 3. Get new dimensions
|
||||
$dims = @getimagesize($tempPath);
|
||||
$width = is_array($dims) && isset($dims[0]) ? (int) $dims[0] : $artwork->width;
|
||||
$height = is_array($dims) && isset($dims[1]) ? (int) $dims[1] : $artwork->height;
|
||||
$size = (int) filesize($originalPath);
|
||||
|
||||
// 4. Update the artwork's file-serving fields (hash drives thumbnail URLs)
|
||||
$origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: '');
|
||||
$artwork->update([
|
||||
'file_name' => $origFilename,
|
||||
'file_path' => '',
|
||||
'file_size' => $size,
|
||||
'mime_type' => $origMime,
|
||||
'hash' => $hash,
|
||||
'file_ext' => $origExt,
|
||||
'thumb_ext' => 'webp',
|
||||
'width' => max(1, $width),
|
||||
'height' => max(1, $height),
|
||||
]);
|
||||
|
||||
// 5. Create version record, apply ranking protection, audit log
|
||||
$version = $this->versioningService->createNewVersion(
|
||||
$artwork,
|
||||
$originalRelative,
|
||||
$hash,
|
||||
max(1, $width),
|
||||
max(1, $height),
|
||||
$size,
|
||||
$request->user()->id,
|
||||
$request->input('change_note'),
|
||||
);
|
||||
|
||||
// 6. Reindex in Meilisearch (non-blocking)
|
||||
try {
|
||||
$this->searchIndexer->update($artwork);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('ArtworkVersioningService: Meilisearch reindex failed', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 7. CDN cache bust — purge thumbnail paths for the old hash
|
||||
$this->purgeCdnCache($artwork, $hash);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'thumb_url' => $artwork->thumbUrl('md'),
|
||||
'thumb_url_lg' => $artwork->thumbUrl('lg'),
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'file_size' => $artwork->file_size,
|
||||
'version_number' => $version->version_number,
|
||||
'requires_reapproval' => (bool) $artwork->requires_reapproval,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('replaceFile: processing error', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return response()->json(['success' => false, 'error' => 'File processing failed: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/studio/artworks/{id}/versions
|
||||
* Return version history for an artwork (newest first).
|
||||
*/
|
||||
public function versions(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
$versions = $artwork->versions()->reorder()->orderByDesc('version_number')->get();
|
||||
|
||||
return response()->json([
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'version_count' => (int) ($artwork->version_count ?? 1),
|
||||
],
|
||||
'versions' => $versions->map(fn (ArtworkVersion $v) => [
|
||||
'id' => $v->id,
|
||||
'version_number' => $v->version_number,
|
||||
'file_path' => $v->file_path,
|
||||
'file_hash' => $v->file_hash,
|
||||
'width' => $v->width,
|
||||
'height' => $v->height,
|
||||
'file_size' => $v->file_size,
|
||||
'change_note' => $v->change_note,
|
||||
'is_current' => $v->is_current,
|
||||
'created_at' => $v->created_at?->toIso8601String(),
|
||||
])->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/studio/artworks/{id}/restore/{version_id}
|
||||
* Restore an earlier version (cloned as a new current version).
|
||||
*/
|
||||
public function restoreVersion(Request $request, int $id, int $versionId): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
$version = ArtworkVersion::where('artwork_id', $artwork->id)->findOrFail($versionId);
|
||||
|
||||
if ($version->is_current) {
|
||||
return response()->json(['success' => false, 'error' => 'This version is already the current version.'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$newVersion = $this->versioningService->restoreVersion($version, $artwork, $request->user()->id);
|
||||
|
||||
// Sync artwork file fields back to restored version dimensions
|
||||
$artwork->update([
|
||||
'width' => max(1, (int) $version->width),
|
||||
'height' => max(1, (int) $version->height),
|
||||
'file_size' => (int) $version->file_size,
|
||||
]);
|
||||
|
||||
$artwork->refresh();
|
||||
|
||||
// Reindex
|
||||
try {
|
||||
$this->searchIndexer->update($artwork);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'version_number' => $newVersion->version_number,
|
||||
'message' => "Version {$version->version_number} has been restored as version {$newVersion->version_number}.",
|
||||
]);
|
||||
} catch (TooManyRequestsHttpException $e) {
|
||||
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['success' => false, 'error' => 'Restore failed: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge CDN thumbnail cache for the artwork.
|
||||
*
|
||||
* This is best-effort; failures are logged but never fatal.
|
||||
* Configure a CDN purge webhook via ARTWORK_CDN_PURGE_URL if needed.
|
||||
*/
|
||||
private function purgeCdnCache(\App\Models\Artwork $artwork, string $oldHash): void
|
||||
{
|
||||
try {
|
||||
$purgeUrl = config('cdn.purge_url');
|
||||
if (empty($purgeUrl)) {
|
||||
Log::debug('CDN purge skipped — cdn.purge_url not configured', ['artwork_id' => $artwork->id]);
|
||||
return;
|
||||
}
|
||||
|
||||
$paths = array_map(
|
||||
fn (string $size) => "/thumbs/{$oldHash}/{$size}.webp",
|
||||
['sm', 'md', 'lg', 'xl']
|
||||
);
|
||||
|
||||
\Illuminate\Support\Facades\Http::timeout(5)->post($purgeUrl, ['paths' => $paths]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('CDN cache purge failed', ['artwork_id' => $artwork->id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
177
app/Http/Controllers/Studio/StudioController.php
Normal file
177
app/Http/Controllers/Studio/StudioController.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Services\Studio\StudioMetricsService;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
/**
|
||||
* Serves Studio Inertia pages for authenticated creators.
|
||||
*/
|
||||
final class StudioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StudioMetricsService $metrics,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Studio Overview Dashboard (/studio)
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
|
||||
return Inertia::render('Studio/StudioDashboard', [
|
||||
'kpis' => $this->metrics->getDashboardKpis($userId),
|
||||
'topPerformers' => $this->metrics->getTopPerformers($userId, 6),
|
||||
'recentComments' => $this->metrics->getRecentComments($userId, 5),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Artwork Manager (/studio/artworks)
|
||||
*/
|
||||
public function artworks(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Studio/StudioArtworks', [
|
||||
'categories' => $this->getCategories(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drafts (/studio/artworks/drafts)
|
||||
*/
|
||||
public function drafts(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Studio/StudioDrafts', [
|
||||
'categories' => $this->getCategories(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archived (/studio/artworks/archived)
|
||||
*/
|
||||
public function archived(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Studio/StudioArchived', [
|
||||
'categories' => $this->getCategories(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit artwork (/studio/artworks/:id/edit)
|
||||
*/
|
||||
public function edit(Request $request, int $id): Response
|
||||
{
|
||||
$artwork = $request->user()->artworks()
|
||||
->with(['stats', 'categories.contentType', 'tags'])
|
||||
->findOrFail($id);
|
||||
|
||||
$primaryCategory = $artwork->categories->first();
|
||||
|
||||
return Inertia::render('Studio/StudioArtworkEdit', [
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'description' => $artwork->description,
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'is_approved' => (bool) $artwork->is_approved,
|
||||
'thumb_url' => $artwork->thumbUrl('md'),
|
||||
'thumb_url_lg' => $artwork->thumbUrl('lg'),
|
||||
'file_name' => $artwork->file_name,
|
||||
'file_size' => $artwork->file_size,
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'mime_type' => $artwork->mime_type,
|
||||
'content_type_id' => $primaryCategory?->contentType?->id,
|
||||
'category_id' => $primaryCategory?->id,
|
||||
'parent_category_id' => $primaryCategory?->parent_id ? $primaryCategory->parent_id : $primaryCategory?->id,
|
||||
'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null,
|
||||
'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug])->values()->all(),
|
||||
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
|
||||
// Versioning
|
||||
'version_count' => (int) ($artwork->version_count ?? 1),
|
||||
'requires_reapproval' => (bool) $artwork->requires_reapproval,
|
||||
],
|
||||
'contentTypes' => $this->getCategories(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analytics v1 (/studio/artworks/:id/analytics)
|
||||
*/
|
||||
public function analytics(Request $request, int $id): Response
|
||||
{
|
||||
$artwork = $request->user()->artworks()
|
||||
->with(['stats', 'awardStat'])
|
||||
->findOrFail($id);
|
||||
|
||||
$stats = $artwork->stats;
|
||||
|
||||
return Inertia::render('Studio/StudioArtworkAnalytics', [
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb_url' => $artwork->thumbUrl('md'),
|
||||
],
|
||||
'analytics' => [
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'favourites' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($stats?->heat_score ?? 0),
|
||||
'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Studio-wide Analytics (/studio/analytics)
|
||||
*/
|
||||
public function analyticsOverview(Request $request): Response
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
$data = $this->metrics->getAnalyticsOverview($userId);
|
||||
|
||||
return Inertia::render('Studio/StudioAnalytics', [
|
||||
'totals' => $data['totals'],
|
||||
'topArtworks' => $data['top_artworks'],
|
||||
'contentBreakdown' => $data['content_breakdown'],
|
||||
'recentComments' => $this->metrics->getRecentComments($userId, 8),
|
||||
]);
|
||||
}
|
||||
|
||||
private function getCategories(): array
|
||||
{
|
||||
return ContentType::with(['rootCategories.children'])->get()->map(function ($ct) {
|
||||
return [
|
||||
'id' => $ct->id,
|
||||
'name' => $ct->name,
|
||||
'slug' => $ct->slug,
|
||||
'categories' => $ct->rootCategories->map(function ($c) {
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
'slug' => $c->slug,
|
||||
'children' => $c->children->map(fn ($ch) => [
|
||||
'id' => $ch->id,
|
||||
'name' => $ch->name,
|
||||
'slug' => $ch->slug,
|
||||
])->values()->all(),
|
||||
];
|
||||
})->values()->all(),
|
||||
];
|
||||
})->values()->all();
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
@@ -120,7 +121,94 @@ class ProfileController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(ProfileUpdateRequest $request, \App\Services\AvatarService $avatarService): RedirectResponse
|
||||
/**
|
||||
* Inertia-powered profile edit page (Settings/ProfileEdit).
|
||||
*/
|
||||
public function editSettings(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Parse birth date parts
|
||||
$birthDay = null;
|
||||
$birthMonth = null;
|
||||
$birthYear = null;
|
||||
|
||||
// Merge modern user_profiles data
|
||||
$profileData = [];
|
||||
try {
|
||||
if (Schema::hasTable('user_profiles')) {
|
||||
$profile = DB::table('user_profiles')->where('user_id', $user->id)->first();
|
||||
if ($profile) {
|
||||
$profileData = (array) $profile;
|
||||
if (isset($profile->website)) $user->homepage = $profile->website;
|
||||
if (isset($profile->about)) $user->about_me = $profile->about;
|
||||
if (isset($profile->birthdate)) $user->birth = $profile->birthdate;
|
||||
if (isset($profile->gender)) $user->gender = $profile->gender;
|
||||
if (isset($profile->country_code)) $user->country_code = $profile->country_code;
|
||||
if (isset($profile->signature)) $user->signature = $profile->signature;
|
||||
if (isset($profile->description)) $user->description = $profile->description;
|
||||
if (isset($profile->mlist)) $user->mlist = $profile->mlist;
|
||||
if (isset($profile->friend_upload_notice)) $user->friend_upload_notice = $profile->friend_upload_notice;
|
||||
if (isset($profile->auto_post_upload)) $user->auto_post_upload = $profile->auto_post_upload;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
if (!empty($user->birth)) {
|
||||
try {
|
||||
$dt = \Carbon\Carbon::parse($user->birth);
|
||||
$birthDay = $dt->format('d');
|
||||
$birthMonth = $dt->format('m');
|
||||
$birthYear = $dt->format('Y');
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
// Country list
|
||||
$countries = collect();
|
||||
try {
|
||||
if (Schema::hasTable('country_list')) {
|
||||
$countries = DB::table('country_list')->orderBy('country_name')->get();
|
||||
} elseif (Schema::hasTable('countries')) {
|
||||
$countries = DB::table('countries')->orderBy('name')->get();
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// Avatar URL
|
||||
$avatarHash = $profileData['avatar_hash'] ?? $user->icon ?? null;
|
||||
$avatarUrl = !empty($avatarHash)
|
||||
? AvatarUrl::forUser((int) $user->id, $avatarHash, 128)
|
||||
: AvatarUrl::default();
|
||||
|
||||
return Inertia::render('Settings/ProfileEdit', [
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'email' => $user->email,
|
||||
'name' => $user->name,
|
||||
'homepage' => $user->homepage ?? $user->website ?? null,
|
||||
'about_me' => $user->about_me ?? $user->about ?? null,
|
||||
'signature' => $user->signature ?? null,
|
||||
'description' => $user->description ?? null,
|
||||
'gender' => $user->gender ?? null,
|
||||
'country_code' => $user->country_code ?? null,
|
||||
'mlist' => $user->mlist ?? false,
|
||||
'friend_upload_notice' => $user->friend_upload_notice ?? false,
|
||||
'auto_post_upload' => $user->auto_post_upload ?? false,
|
||||
'username_changed_at' => $user->username_changed_at,
|
||||
],
|
||||
'avatarUrl' => $avatarUrl,
|
||||
'birthDay' => $birthDay,
|
||||
'birthMonth' => $birthMonth,
|
||||
'birthYear' => $birthYear,
|
||||
'countries' => $countries->values(),
|
||||
'flash' => [
|
||||
'status' => session('status'),
|
||||
'error' => session('error'),
|
||||
],
|
||||
])->rootView('settings');
|
||||
}
|
||||
|
||||
public function update(ProfileUpdateRequest $request, \App\Services\AvatarService $avatarService): RedirectResponse|JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
@@ -143,18 +231,22 @@ class ProfileController extends Controller
|
||||
'current_username' => $currentUsername,
|
||||
]);
|
||||
|
||||
return Redirect::back()->withErrors([
|
||||
'username' => 'This username is too similar to a reserved name and requires manual approval.',
|
||||
]);
|
||||
$error = ['username' => ['This username is too similar to a reserved name and requires manual approval.']];
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['errors' => $error], 422);
|
||||
}
|
||||
return Redirect::back()->withErrors($error);
|
||||
}
|
||||
|
||||
$cooldownDays = (int) config('usernames.rename_cooldown_days', 90);
|
||||
$isAdmin = method_exists($user, 'isAdmin') ? $user->isAdmin() : false;
|
||||
|
||||
if (! $isAdmin && $user->username_changed_at !== null && $user->username_changed_at->gt(now()->subDays($cooldownDays))) {
|
||||
return Redirect::back()->withErrors([
|
||||
'username' => "Username can only be changed once every {$cooldownDays} days.",
|
||||
]);
|
||||
$error = ['username' => ["Username can only be changed once every {$cooldownDays} days."]];
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['errors' => $error], 422);
|
||||
}
|
||||
return Redirect::back()->withErrors($error);
|
||||
}
|
||||
|
||||
$user->username = $incomingUsername;
|
||||
@@ -220,6 +312,10 @@ class ProfileController extends Controller
|
||||
$profileUpdates['friend_upload_notice'] = filter_var($validated['notify'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
|
||||
}
|
||||
|
||||
if (array_key_exists('auto_post_upload', $validated)) {
|
||||
$profileUpdates['auto_post_upload'] = filter_var($validated['auto_post_upload'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
|
||||
}
|
||||
|
||||
if (isset($validated['signature'])) $profileUpdates['signature'] = $validated['signature'];
|
||||
if (isset($validated['description'])) $profileUpdates['description'] = $validated['description'];
|
||||
|
||||
@@ -229,6 +325,9 @@ class ProfileController extends Controller
|
||||
try {
|
||||
$avatarService->storeFromUploadedFile($user->id, $request->file('avatar'));
|
||||
} catch (\Exception $e) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['errors' => ['avatar' => ['Avatar processing failed: ' . $e->getMessage()]]], 422);
|
||||
}
|
||||
return Redirect::back()->with('error', 'Avatar processing failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -269,12 +368,17 @@ class ProfileController extends Controller
|
||||
logger()->error('Profile update error: '.$e->getMessage());
|
||||
}
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
return Redirect::route('dashboard.profile')->with('status', 'profile-updated');
|
||||
}
|
||||
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
public function destroy(Request $request): RedirectResponse|JsonResponse
|
||||
{
|
||||
$request->validateWithBag('userDeletion', [
|
||||
$bag = $request->expectsJson() ? 'default' : 'userDeletion';
|
||||
$request->validateWithBag($bag, [
|
||||
'password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
@@ -287,10 +391,14 @@ class ProfileController extends Controller
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
return Redirect::to('/');
|
||||
}
|
||||
|
||||
public function password(Request $request): RedirectResponse
|
||||
public function password(Request $request): RedirectResponse|JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'current_password' => ['required', 'current_password'],
|
||||
@@ -301,6 +409,10 @@ class ProfileController extends Controller
|
||||
$user->password = Hash::make($request->input('password'));
|
||||
$user->save();
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
return Redirect::route('dashboard.profile')->with('status', 'password-updated');
|
||||
}
|
||||
|
||||
@@ -498,24 +610,70 @@ class ProfileController extends Controller
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
return response()->view('legacy::profile', [
|
||||
'user' => $user,
|
||||
'profile' => $profile,
|
||||
'artworks' => $artworks,
|
||||
'featuredArtworks' => $featuredArtworks,
|
||||
'favourites' => $favourites,
|
||||
// ── Normalise artworks for JSON serialisation ────────────────────
|
||||
$artworkItems = collect($artworks->items())->values();
|
||||
$artworkPayload = [
|
||||
'data' => $artworkItems,
|
||||
'next_cursor' => $artworks->nextCursor()?->encode(),
|
||||
'has_more' => $artworks->hasMorePages(),
|
||||
];
|
||||
|
||||
// ── Avatar URL on user object ────────────────────────────────────
|
||||
$avatarUrl = AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 128);
|
||||
|
||||
// ── Auth context for JS ───────────────────────────────────────────
|
||||
$authData = null;
|
||||
if (Auth::check()) {
|
||||
/** @var \App\Models\User $authUser */
|
||||
$authUser = Auth::user();
|
||||
$authAvatarUrl = AvatarUrl::forUser((int) $authUser->id, $authUser->profile?->avatar_hash, 64);
|
||||
$authData = [
|
||||
'user' => [
|
||||
'id' => $authUser->id,
|
||||
'username' => $authUser->username,
|
||||
'name' => $authUser->name,
|
||||
'avatar' => $authAvatarUrl,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$canonical = url('/@' . strtolower((string) ($user->username ?? '')));
|
||||
|
||||
return Inertia::render('Profile/ProfileShow', [
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'name' => $user->name,
|
||||
'avatar_url' => $avatarUrl,
|
||||
'created_at' => $user->created_at?->toISOString(),
|
||||
'last_visit_at' => $user->last_visit_at ? (string) $user->last_visit_at : null,
|
||||
],
|
||||
'profile' => $profile ? [
|
||||
'about' => $profile->about ?? null,
|
||||
'website' => $profile->website ?? null,
|
||||
'country_code' => $profile->country_code ?? null,
|
||||
'gender' => $profile->gender ?? null,
|
||||
'birthdate' => $profile->birthdate ?? null,
|
||||
'cover_image' => $profile->cover_image ?? null,
|
||||
] : null,
|
||||
'artworks' => $artworkPayload,
|
||||
'featuredArtworks' => $featuredArtworks->values(),
|
||||
'favourites' => $favourites->values(),
|
||||
'stats' => $stats,
|
||||
'socialLinks' => $socialLinks,
|
||||
'followerCount' => $followerCount,
|
||||
'recentFollowers' => $recentFollowers,
|
||||
'recentFollowers' => $recentFollowers->values(),
|
||||
'viewerIsFollowing' => $viewerIsFollowing,
|
||||
'heroBgUrl' => $heroBgUrl,
|
||||
'profileComments' => $profileComments,
|
||||
'profileComments' => $profileComments->values(),
|
||||
'countryName' => $countryName,
|
||||
'isOwner' => $isOwner,
|
||||
'page_title' => 'Profile: ' . ($user->username ?? $user->name ?? ''),
|
||||
'page_canonical' => url('/@' . strtolower((string) ($user->username ?? ''))),
|
||||
'auth' => $authData,
|
||||
])->withViewData([
|
||||
'page_title' => ($user->username ?? $user->name ?? 'User') . ' on Skinbase',
|
||||
'page_canonical' => $canonical,
|
||||
'page_meta_description' => 'View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.',
|
||||
'og_image' => $avatarUrl,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class TodayDownloadsController extends Controller
|
||||
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$encoded = null;
|
||||
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? $present['url'] : '/gfx/sb_join.jpg';
|
||||
$thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$categoryId = $art->categories->first()->id ?? null;
|
||||
|
||||
return (object) [
|
||||
|
||||
@@ -68,11 +68,11 @@ class TodayInHistoryController extends Controller
|
||||
/** @var ?Artwork $art */
|
||||
$art = $modelsById->get($row->id);
|
||||
if ($art) {
|
||||
$row->thumb_url = $art->thumbUrl('md') ?? '/gfx/sb_join.jpg';
|
||||
$row->thumb_url = $art->thumbUrl('md') ?? 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$row->art_url = '/art/' . $art->id . '/' . $art->slug;
|
||||
$row->name = $art->title ?: ($row->name ?? 'Untitled');
|
||||
} else {
|
||||
$row->thumb_url = '/gfx/sb_join.jpg';
|
||||
$row->thumb_url = 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$row->art_url = '/art/' . $row->id;
|
||||
$row->name = $row->name ?? 'Untitled';
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@ class TopAuthorsController extends Controller
|
||||
$query = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
|
||||
->mergeBindings($sub->getQuery())
|
||||
->join('users as u', 'u.id', '=', 't.user_id')
|
||||
->select('u.id as user_id', 'u.name as uname', 'u.username', 't.total_metric', 't.latest_published')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->select('u.id as user_id', 'u.name as uname', 'u.username', 'up.avatar_hash', 't.total_metric', 't.latest_published')
|
||||
->orderByDesc('t.total_metric')
|
||||
->orderByDesc('t.latest_published');
|
||||
|
||||
@@ -44,6 +45,7 @@ class TopAuthorsController extends Controller
|
||||
'user_id' => $row->user_id,
|
||||
'uname' => $row->uname,
|
||||
'username' => $row->username,
|
||||
'avatar_hash' => $row->avatar_hash,
|
||||
'total' => (int) $row->total_metric,
|
||||
'metric' => $metric,
|
||||
];
|
||||
|
||||
93
app/Http/Controllers/Web/ApplicationController.php
Normal file
93
app/Http/Controllers/Web/ApplicationController.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\StaffApplication;
|
||||
|
||||
class ApplicationController extends Controller
|
||||
{
|
||||
public function show()
|
||||
{
|
||||
return view('web.apply');
|
||||
}
|
||||
|
||||
public function submit(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'topic' => 'required|string|in:apply,bug,contact,other',
|
||||
'name' => 'required|string|max:100',
|
||||
'email' => 'required|email|max:150',
|
||||
'role' => 'nullable|string|max:100',
|
||||
'portfolio' => 'nullable|url|max:255',
|
||||
'affected_url' => 'nullable|url|max:255',
|
||||
'steps' => 'nullable|string|max:2000',
|
||||
'message' => 'nullable|string|max:2000',
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'id' => (string) Str::uuid(),
|
||||
'submitted_at' => now()->toISOString(),
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'data' => $data,
|
||||
];
|
||||
|
||||
// Honeypot: silently drop submissions that fill the hidden field
|
||||
if ($request->filled('website')) {
|
||||
return redirect()->route('contact.show')->with('success', 'Your submission was received.');
|
||||
}
|
||||
|
||||
try {
|
||||
Storage::append('staff_applications.jsonl', json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
} catch (\Throwable $e) {
|
||||
// best-effort store; don't fail the user if write fails
|
||||
}
|
||||
|
||||
// store in DB as well
|
||||
try {
|
||||
StaffApplication::create([
|
||||
'id' => $payload['id'],
|
||||
'topic' => $data['topic'] ?? 'apply',
|
||||
'name' => $data['name'] ?? null,
|
||||
'email' => $data['email'] ?? null,
|
||||
'role' => $data['role'] ?? null,
|
||||
'portfolio' => $data['portfolio'] ?? null,
|
||||
'message' => $data['message'] ?? null,
|
||||
'payload' => $payload,
|
||||
'ip' => $payload['ip'],
|
||||
'user_agent' => $payload['user_agent'],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// ignore DB errors
|
||||
}
|
||||
|
||||
$to = config('mail.from.address');
|
||||
|
||||
if ($to) {
|
||||
try {
|
||||
// prefer the DB model when available
|
||||
$appModel = isset($appModel) ? $appModel : StaffApplication::find($payload['id']) ?? null;
|
||||
if (! $appModel) {
|
||||
// construct a lightweight model-like object for the mailable
|
||||
$appModel = new StaffApplication($payload['data'] ?? []);
|
||||
$appModel->id = $payload['id'];
|
||||
$appModel->payload = $payload;
|
||||
$appModel->ip = $payload['ip'];
|
||||
$appModel->user_agent = $payload['user_agent'];
|
||||
$appModel->created_at = now();
|
||||
}
|
||||
|
||||
Mail::to($to)->queue(new \App\Mail\StaffApplicationReceived($appModel));
|
||||
} catch (\Throwable $e) {
|
||||
// ignore mail errors but don't fail user
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('contact.show')->with('success', 'Your submission was received. Thank you — we will review it soon.');
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,77 @@ use App\Http\Resources\ArtworkResource;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\ErrorSuggestionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
final class ArtworkPageController extends Controller
|
||||
{
|
||||
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse
|
||||
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response
|
||||
{
|
||||
// ── Step 1: check existence including soft-deleted ─────────────────
|
||||
$raw = Artwork::withTrashed()->where('id', $id)->first();
|
||||
|
||||
if (! $raw) {
|
||||
// Artwork never existed → contextual 404
|
||||
$suggestions = app(ErrorSuggestionService::class);
|
||||
return response(view('errors.contextual.artwork-not-found', [
|
||||
'trendingArtworks' => $this->safeSuggestions(fn () => $suggestions->trendingArtworks()),
|
||||
]), 404);
|
||||
}
|
||||
|
||||
if ($raw->trashed()) {
|
||||
// Artwork permanently deleted → 410 Gone
|
||||
return response(view('errors.410'), 410);
|
||||
}
|
||||
|
||||
if (! $raw->is_public || ! $raw->is_approved) {
|
||||
// Artwork exists but is private/unapproved → 403 Forbidden.
|
||||
// Show other public artworks by the same creator as recovery suggestions.
|
||||
$suggestions = app(ErrorSuggestionService::class);
|
||||
$creatorArtworks = collect();
|
||||
$creatorUsername = null;
|
||||
|
||||
if ($raw->user_id) {
|
||||
$raw->loadMissing('user');
|
||||
$creatorUsername = $raw->user?->username;
|
||||
|
||||
$creatorArtworks = $this->safeSuggestions(function () use ($raw) {
|
||||
return Artwork::query()
|
||||
->with('user')
|
||||
->where('user_id', $raw->user_id)
|
||||
->where('id', '!=', $raw->id)
|
||||
->public()
|
||||
->published()
|
||||
->limit(6)
|
||||
->get()
|
||||
->map(function (Artwork $a) {
|
||||
$slug = \Illuminate\Support\Str::slug((string) ($a->slug ?: $a->title)) ?: (string) $a->id;
|
||||
$md = \App\Services\ThumbnailPresenter::present($a, 'md');
|
||||
return [
|
||||
'id' => $a->id,
|
||||
'title' => html_entity_decode((string) $a->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'author' => html_entity_decode((string) ($a->user?->name ?: $a->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'url' => route('art.show', ['id' => $a->id, 'slug' => $slug]),
|
||||
'thumb' => $md['url'] ?? null,
|
||||
];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return response(view('errors.contextual.artwork-not-found', [
|
||||
'message' => 'This artwork is not publicly available.',
|
||||
'isForbidden' => true,
|
||||
'creatorArtworks' => $creatorArtworks,
|
||||
'creatorUsername' => $creatorUsername,
|
||||
'trendingArtworks' => $this->safeSuggestions(fn () => $suggestions->trendingArtworks()),
|
||||
]), 403);
|
||||
}
|
||||
|
||||
// ── Step 2: full load with all relations ───────────────────────────
|
||||
$artwork = Artwork::with(['user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats'])
|
||||
->where('id', $id)
|
||||
->public()
|
||||
@@ -150,4 +212,14 @@ final class ArtworkPageController extends Controller
|
||||
'comments' => $comments,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Silently catch suggestion query failures so error page never crashes. */
|
||||
private function safeSuggestions(callable $fn): mixed
|
||||
{
|
||||
try {
|
||||
return $fn();
|
||||
} catch (\Throwable) {
|
||||
return collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
52
app/Http/Controllers/Web/BlogController.php
Normal file
52
app/Http/Controllers/Web/BlogController.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BlogPost;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* BlogController — /blog index + single post.
|
||||
*/
|
||||
final class BlogController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$posts = BlogPost::published()
|
||||
->orderByDesc('published_at')
|
||||
->paginate(12)
|
||||
->withQueryString();
|
||||
|
||||
return view('web.blog.index', [
|
||||
'posts' => $posts,
|
||||
'page_title' => 'Blog — Skinbase',
|
||||
'page_meta_description' => 'News, tutorials and community stories from the Skinbase team.',
|
||||
'page_canonical' => url('/blog'),
|
||||
'page_robots' => 'index,follow',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Blog', 'url' => '/blog'],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(string $slug): View
|
||||
{
|
||||
$post = BlogPost::published()->where('slug', $slug)->firstOrFail();
|
||||
|
||||
return view('web.blog.show', [
|
||||
'post' => $post,
|
||||
'page_title' => ($post->meta_title ?: $post->title) . ' — Skinbase Blog',
|
||||
'page_meta_description' => $post->meta_description ?: $post->excerpt ?: '',
|
||||
'page_canonical' => $post->url,
|
||||
'page_robots' => 'index,follow',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Blog', 'url' => '/blog'],
|
||||
(object) ['name' => $post->title, 'url' => $post->url],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
57
app/Http/Controllers/Web/BugReportController.php
Normal file
57
app/Http/Controllers/Web/BugReportController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BugReport;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* BugReportController — /bug-report
|
||||
*
|
||||
* GET /bug-report → show form (guests see a login prompt)
|
||||
* POST /bug-report → authenticated users submit a report
|
||||
*/
|
||||
final class BugReportController extends Controller
|
||||
{
|
||||
public function show(Request $request): View
|
||||
{
|
||||
return view('web.bug-report', [
|
||||
'page_title' => 'Bug Report — Skinbase',
|
||||
'page_meta_description' => 'Submit a bug report or suggestion to the Skinbase team.',
|
||||
'page_canonical' => url('/bug-report'),
|
||||
'hero_title' => 'Bug Report',
|
||||
'hero_description' => 'Found something broken? Submit a report and our team will look into it.',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Home', 'url' => '/'],
|
||||
(object) ['name' => 'Bug Report', 'url' => '/bug-report'],
|
||||
]),
|
||||
'success' => session('bug_report_success', false),
|
||||
'center_content' => true,
|
||||
'center_max' => '3xl',
|
||||
]);
|
||||
}
|
||||
|
||||
public function submit(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'subject' => ['required', 'string', 'max:255'],
|
||||
'description' => ['required', 'string', 'max:5000'],
|
||||
]);
|
||||
|
||||
BugReport::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'subject' => $validated['subject'],
|
||||
'description' => $validated['description'],
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => substr($request->userAgent() ?? '', 0, 512),
|
||||
'status' => 'open',
|
||||
]);
|
||||
|
||||
return redirect()->route('bug-report')->with('bug_report_success', true);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\EarlyGrowth\FeedBlender;
|
||||
use App\Services\EarlyGrowth\GridFiller;
|
||||
use App\Services\Recommendation\RecommendationService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -30,6 +32,8 @@ final class DiscoverController extends Controller
|
||||
private readonly ArtworkService $artworkService,
|
||||
private readonly ArtworkSearchService $searchService,
|
||||
private readonly RecommendationService $recoService,
|
||||
private readonly FeedBlender $feedBlender,
|
||||
private readonly GridFiller $gridFiller,
|
||||
) {}
|
||||
|
||||
// ─── /discover/trending ──────────────────────────────────────────────────
|
||||
@@ -37,7 +41,9 @@ final class DiscoverController extends Controller
|
||||
public function trending(Request $request)
|
||||
{
|
||||
$perPage = 24;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$results = $this->searchService->discoverTrending($perPage);
|
||||
$results = $this->gridFiller->fill($results, 0, $page);
|
||||
$this->hydrateDiscoverSearchResults($results);
|
||||
|
||||
return view('web.discover.index', [
|
||||
@@ -49,12 +55,35 @@ final class DiscoverController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── /discover/rising ────────────────────────────────────────────────────
|
||||
|
||||
public function rising(Request $request)
|
||||
{
|
||||
$perPage = 24;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$results = $this->searchService->discoverRising($perPage);
|
||||
$results = $this->gridFiller->fill($results, 0, $page);
|
||||
$this->hydrateDiscoverSearchResults($results);
|
||||
|
||||
return view('web.discover.index', [
|
||||
'artworks' => $results,
|
||||
'page_title' => 'Rising Now',
|
||||
'section' => 'rising',
|
||||
'description' => 'Fastest growing artworks right now.',
|
||||
'icon' => 'fa-rocket',
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── /discover/fresh ─────────────────────────────────────────────────────
|
||||
|
||||
public function fresh(Request $request)
|
||||
{
|
||||
$perPage = 24;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$results = $this->searchService->discoverFresh($perPage);
|
||||
// EGS: blend fresh feed with curated + spotlight on page 1
|
||||
$results = $this->feedBlender->blend($results, $perPage, $page);
|
||||
$results = $this->gridFiller->fill($results, 0, $page);
|
||||
$this->hydrateDiscoverSearchResults($results);
|
||||
|
||||
return view('web.discover.index', [
|
||||
@@ -71,7 +100,9 @@ final class DiscoverController extends Controller
|
||||
public function topRated(Request $request)
|
||||
{
|
||||
$perPage = 24;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$results = $this->searchService->discoverTopRated($perPage);
|
||||
$results = $this->gridFiller->fill($results, 0, $page);
|
||||
$this->hydrateDiscoverSearchResults($results);
|
||||
|
||||
return view('web.discover.index', [
|
||||
@@ -88,7 +119,9 @@ final class DiscoverController extends Controller
|
||||
public function mostDownloaded(Request $request)
|
||||
{
|
||||
$perPage = 24;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$results = $this->searchService->discoverMostDownloaded($perPage);
|
||||
$results = $this->gridFiller->fill($results, 0, $page);
|
||||
$this->hydrateDiscoverSearchResults($results);
|
||||
|
||||
return view('web.discover.index', [
|
||||
@@ -163,7 +196,8 @@ final class DiscoverController extends Controller
|
||||
$creators = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
|
||||
->mergeBindings($sub->getQuery())
|
||||
->join('users as u', 'u.id', '=', 't.user_id')
|
||||
->select('u.id as user_id', 'u.name as uname', 'u.username', 't.recent_views', 't.latest_published')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 't.user_id')
|
||||
->select('u.id as user_id', 'u.name as uname', 'u.username', 't.recent_views', 't.latest_published', 'up.avatar_hash')
|
||||
->orderByDesc('t.recent_views')
|
||||
->orderByDesc('t.latest_published')
|
||||
->paginate($perPage)
|
||||
@@ -176,6 +210,7 @@ final class DiscoverController extends Controller
|
||||
'username' => $row->username,
|
||||
'total' => (int) $row->recent_views,
|
||||
'metric' => 'views',
|
||||
'avatar_hash' => $row->avatar_hash ?? null,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
112
app/Http/Controllers/Web/ErrorController.php
Normal file
112
app/Http/Controllers/Web/ErrorController.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ErrorSuggestionService;
|
||||
use App\Services\NotFoundLogger;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
/**
|
||||
* ErrorController
|
||||
*
|
||||
* Handles contextual 404 rendering.
|
||||
* Invoked from bootstrap/app.php exception handler for web 404s.
|
||||
*
|
||||
* Pattern detection:
|
||||
* /blog/* → blog-not-found (latest posts)
|
||||
* /tag/* → tag-not-found (similar + trending tags)
|
||||
* /@username → creator-not-found (trending creators)
|
||||
* /pages/* → page-not-found
|
||||
* /about|/help etc. → page-not-found
|
||||
* everything else → generic 404 (trending artworks + tags)
|
||||
*/
|
||||
final class ErrorController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ErrorSuggestionService $suggestions,
|
||||
private readonly NotFoundLogger $logger,
|
||||
) {}
|
||||
|
||||
public function handleNotFound(Request $request): Response|JsonResponse
|
||||
{
|
||||
// For JSON / Inertia API requests return a minimal JSON 404.
|
||||
if ($request->expectsJson() || $request->header('X-Inertia')) {
|
||||
return response()->json(['message' => 'Not Found'], 404);
|
||||
}
|
||||
|
||||
// Log every 404 hit for later analysis.
|
||||
try {
|
||||
$this->logger->log404($request);
|
||||
} catch (\Throwable) {
|
||||
// Never let the logger itself break the error page.
|
||||
}
|
||||
|
||||
$path = ltrim($request->path(), '/');
|
||||
|
||||
// ── /blog/* ──────────────────────────────────────────────────────────
|
||||
if (str_starts_with($path, 'blog/')) {
|
||||
return response(view('errors.contextual.blog-not-found', [
|
||||
'latestPosts' => $this->safeFetch(fn () => $this->suggestions->latestBlogPosts()),
|
||||
]), 404);
|
||||
}
|
||||
|
||||
// ── /tag/* ───────────────────────────────────────────────────────────
|
||||
if (str_starts_with($path, 'tag/')) {
|
||||
$slug = ltrim(substr($path, 4), '/');
|
||||
return response(view('errors.contextual.tag-not-found', [
|
||||
'requestedSlug' => $slug,
|
||||
'similarTags' => $this->safeFetch(fn () => $this->suggestions->similarTags($slug)),
|
||||
'trendingTags' => $this->safeFetch(fn () => $this->suggestions->trendingTags()),
|
||||
]), 404);
|
||||
}
|
||||
|
||||
// ── /@username or /creator/* ───────────────────────────────────────
|
||||
if (str_starts_with($path, '@') || str_starts_with($path, 'creator/')) {
|
||||
$username = str_starts_with($path, '@') ? substr($path, 1) : null;
|
||||
return response(view('errors.contextual.creator-not-found', [
|
||||
'requestedUsername' => $username,
|
||||
'trendingCreators' => $this->safeFetch(fn () => $this->suggestions->trendingCreators()),
|
||||
'recentCreators' => $this->safeFetch(fn () => $this->suggestions->recentlyJoinedCreators()),
|
||||
]), 404);
|
||||
}
|
||||
|
||||
// ── /{contentType}/{category}/{artwork-slug} — artwork not found ──────
|
||||
if (preg_match('#^(wallpapers|skins|photography|other)/#', $path)) {
|
||||
return response(view('errors.contextual.artwork-not-found', [
|
||||
'trendingArtworks' => $this->safeFetch(fn () => $this->suggestions->trendingArtworks()),
|
||||
]), 404);
|
||||
}
|
||||
|
||||
// ── /pages/* or /about | /help | /contact | /legal/* ───────────────
|
||||
if (
|
||||
str_starts_with($path, 'pages/')
|
||||
|| in_array($path, ['about', 'help', 'contact', 'faq', 'staff', 'privacy-policy', 'terms-of-service', 'rules-and-guidelines'])
|
||||
|| str_starts_with($path, 'legal/')
|
||||
) {
|
||||
return response(view('errors.contextual.page-not-found'), 404);
|
||||
}
|
||||
|
||||
// ── Generic 404 ───────────────────────────────────────────────────────
|
||||
return response(view('errors.404', [
|
||||
'trendingArtworks' => $this->safeFetch(fn () => $this->suggestions->trendingArtworks()),
|
||||
'trendingTags' => $this->safeFetch(fn () => $this->suggestions->trendingTags()),
|
||||
]), 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Silently catch any DB/cache error so the error page itself never crashes.
|
||||
*/
|
||||
private function safeFetch(callable $fn): mixed
|
||||
{
|
||||
try {
|
||||
return $fn();
|
||||
} catch (\Throwable) {
|
||||
return collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
252
app/Http/Controllers/Web/ExploreController.php
Normal file
252
app/Http/Controllers/Web/ExploreController.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ContentType;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\EarlyGrowth\EarlyGrowth;
|
||||
use App\Services\EarlyGrowth\GridFiller;
|
||||
use App\Services\EarlyGrowth\SpotlightEngineInterface;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\AbstractCursorPaginator;
|
||||
use Illuminate\Pagination\AbstractPaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* ExploreController
|
||||
*
|
||||
* Powers the /explore/* structured catalog pages (§3.2 of routing spec).
|
||||
* Delegates to the same Meilisearch pipeline as BrowseGalleryController but
|
||||
* uses canonical /explore/* URLs with the ExploreLayout blade template.
|
||||
*/
|
||||
final class ExploreController extends Controller
|
||||
{
|
||||
private const CONTENT_TYPE_SLUGS = ['artworks', 'wallpapers', 'skins', 'photography', 'other'];
|
||||
|
||||
/** Meilisearch sort-field arrays per sort alias. */
|
||||
private const SORT_MAP = [
|
||||
'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
|
||||
'new-hot' => ['trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
|
||||
'best' => ['awards_received_count:desc', 'favorites_count:desc'],
|
||||
'latest' => ['created_at:desc'],
|
||||
];
|
||||
|
||||
private const SORT_TTL = [
|
||||
'trending' => 300,
|
||||
'new-hot' => 120,
|
||||
'best' => 600,
|
||||
'latest' => 120,
|
||||
];
|
||||
|
||||
private const SORT_OPTIONS = [
|
||||
['value' => 'trending', 'label' => '🔥 Trending'],
|
||||
['value' => 'new-hot', 'label' => '🚀 New & Hot'],
|
||||
['value' => 'best', 'label' => '⭐ Best'],
|
||||
['value' => 'latest', 'label' => '🕐 Latest'],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ArtworkSearchService $search,
|
||||
private readonly GridFiller $gridFiller,
|
||||
private readonly SpotlightEngineInterface $spotlight,
|
||||
) {}
|
||||
|
||||
// ── /explore (hub) ──────────────────────────────────────────────────
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$sort = $this->resolveSort($request);
|
||||
$perPage = $this->resolvePerPage($request);
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$ttl = self::SORT_TTL[$sort] ?? 300;
|
||||
|
||||
$artworks = Cache::remember("explore.all.{$sort}.{$page}", $ttl, fn () =>
|
||||
Artwork::search('')->options([
|
||||
'filter' => 'is_public = true AND is_approved = true',
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
])->paginate($perPage)
|
||||
);
|
||||
// EGS: fill grid to minimum when uploads are sparse
|
||||
$artworks = $this->gridFiller->fill($artworks, 0, $page);
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
|
||||
// EGS §11: featured spotlight row on page 1 only
|
||||
$spotlightItems = ($page === 1 && EarlyGrowth::spotlightEnabled())
|
||||
? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a))
|
||||
: collect();
|
||||
|
||||
$contentTypes = $this->contentTypeLinks();
|
||||
$seo = $this->paginationSeo($request, url('/explore'), $artworks);
|
||||
|
||||
return view('web.explore.index', [
|
||||
'artworks' => $artworks,
|
||||
'spotlight' => $spotlightItems,
|
||||
'contentTypes' => $contentTypes,
|
||||
'activeType' => null,
|
||||
'current_sort' => $sort,
|
||||
'sort_options' => self::SORT_OPTIONS,
|
||||
'hero_title' => 'Explore',
|
||||
'hero_description' => 'Browse the full Skinbase catalog — wallpapers, skins, photography and more.',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Explore', 'url' => '/explore'],
|
||||
]),
|
||||
'page_title' => 'Explore Artworks — Skinbase',
|
||||
'page_meta_description' => 'Explore the full catalog of wallpapers, skins, photography and other artworks on Skinbase.',
|
||||
'page_canonical' => $seo['canonical'],
|
||||
'page_rel_prev' => $seo['prev'],
|
||||
'page_rel_next' => $seo['next'],
|
||||
'page_robots' => 'index,follow',
|
||||
]);
|
||||
}
|
||||
|
||||
// ── /explore/:type ──────────────────────────────────────────────────
|
||||
|
||||
public function byType(Request $request, string $type)
|
||||
{
|
||||
$type = strtolower($type);
|
||||
if (!in_array($type, self::CONTENT_TYPE_SLUGS, true)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// "artworks" is the umbrella — search all types
|
||||
$isAll = $type === 'artworks';
|
||||
|
||||
$sort = $this->resolveSort($request);
|
||||
$perPage = $this->resolvePerPage($request);
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$ttl = self::SORT_TTL[$sort] ?? 300;
|
||||
|
||||
$filter = 'is_public = true AND is_approved = true';
|
||||
if (!$isAll) {
|
||||
$filter .= ' AND content_type = "' . $type . '"';
|
||||
}
|
||||
|
||||
$artworks = Cache::remember("explore.{$type}.{$sort}.{$page}", $ttl, fn () =>
|
||||
Artwork::search('')->options([
|
||||
'filter' => $filter,
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
])->paginate($perPage)
|
||||
);
|
||||
// EGS: fill grid to minimum when uploads are sparse
|
||||
$artworks = $this->gridFiller->fill($artworks, 0, $page);
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
|
||||
// EGS §11: featured spotlight row on page 1 only
|
||||
$spotlightItems = ($page === 1 && EarlyGrowth::spotlightEnabled())
|
||||
? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a))
|
||||
: collect();
|
||||
|
||||
$contentTypes = $this->contentTypeLinks();
|
||||
$baseUrl = url('/explore/' . $type);
|
||||
$seo = $this->paginationSeo($request, $baseUrl, $artworks);
|
||||
$humanType = ucfirst($type);
|
||||
|
||||
return view('web.explore.index', [
|
||||
'artworks' => $artworks,
|
||||
'spotlight' => $spotlightItems,
|
||||
'contentTypes' => $contentTypes,
|
||||
'activeType' => $type,
|
||||
'current_sort' => $sort,
|
||||
'sort_options' => self::SORT_OPTIONS,
|
||||
'hero_title' => $humanType,
|
||||
'hero_description' => "Browse {$humanType} on Skinbase.",
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Explore', 'url' => '/explore'],
|
||||
(object) ['name' => $humanType, 'url' => "/explore/{$type}"],
|
||||
]),
|
||||
'page_title' => "{$humanType} — Explore — Skinbase",
|
||||
'page_meta_description' => "Discover the best {$humanType} artworks on Skinbase. Browse trending, new and top-rated.",
|
||||
'page_canonical' => $seo['canonical'],
|
||||
'page_rel_prev' => $seo['prev'],
|
||||
'page_rel_next' => $seo['next'],
|
||||
'page_robots' => 'index,follow',
|
||||
]);
|
||||
}
|
||||
|
||||
// ── /explore/:type/:mode ────────────────────────────────────────────
|
||||
|
||||
public function byTypeMode(Request $request, string $type, string $mode)
|
||||
{
|
||||
// Rewrite the sort via the URL segment and delegate
|
||||
$request->query->set('sort', $mode);
|
||||
return $this->byType($request, $type);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private function contentTypeLinks(): Collection
|
||||
{
|
||||
return collect([
|
||||
(object) ['name' => 'All Artworks', 'slug' => 'artworks', 'url' => '/explore/artworks'],
|
||||
...ContentType::orderBy('id')->get(['name', 'slug'])->map(fn ($ct) => (object) [
|
||||
'name' => $ct->name,
|
||||
'slug' => $ct->slug,
|
||||
'url' => '/explore/' . strtolower($ct->slug),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveSort(Request $request): string
|
||||
{
|
||||
$s = (string) $request->query('sort', 'trending');
|
||||
return array_key_exists($s, self::SORT_MAP) ? $s : 'trending';
|
||||
}
|
||||
|
||||
private function resolvePerPage(Request $request): int
|
||||
{
|
||||
$v = (int) ($request->query('per_page') ?: $request->query('limit') ?: 24);
|
||||
return max(12, min($v, 80));
|
||||
}
|
||||
|
||||
private function presentArtwork(Artwork $artwork): object
|
||||
{
|
||||
$primary = $artwork->categories->sortBy('sort_order')->first();
|
||||
$present = ThumbnailPresenter::present($artwork, 'md');
|
||||
$avatarUrl = \App\Support\AvatarUrl::forUser(
|
||||
(int) ($artwork->user_id ?? 0),
|
||||
$artwork->user?->profile?->avatar_hash ?? null,
|
||||
64
|
||||
);
|
||||
|
||||
return (object) [
|
||||
'id' => $artwork->id,
|
||||
'name' => $artwork->title,
|
||||
'category_name' => $primary->name ?? '',
|
||||
'category_slug' => $primary->slug ?? '',
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user?->name ?? 'Skinbase',
|
||||
'username' => $artwork->user?->username ?? '',
|
||||
'avatar_url' => $avatarUrl,
|
||||
'published_at' => $artwork->published_at,
|
||||
'slug' => $artwork->slug ?? '',
|
||||
'width' => $artwork->width ?? null,
|
||||
'height' => $artwork->height ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function paginationSeo(Request $request, string $base, mixed $paginator): array
|
||||
{
|
||||
$q = $request->query();
|
||||
unset($q['grid']);
|
||||
if (($q['page'] ?? null) !== null && (int) $q['page'] <= 1) {
|
||||
unset($q['page']);
|
||||
}
|
||||
$canonical = $base . ($q ? '?' . http_build_query($q) : '');
|
||||
|
||||
$prev = null;
|
||||
$next = null;
|
||||
if ($paginator instanceof AbstractPaginator || $paginator instanceof AbstractCursorPaginator) {
|
||||
$prev = $paginator->previousPageUrl();
|
||||
$next = $paginator->nextPageUrl();
|
||||
}
|
||||
|
||||
return compact('canonical', 'prev', 'next');
|
||||
}
|
||||
}
|
||||
87
app/Http/Controllers/Web/FooterController.php
Normal file
87
app/Http/Controllers/Web/FooterController.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* FooterController — serves static footer pages.
|
||||
*
|
||||
* /faq → faq()
|
||||
* /rules-and-guidelines → rules()
|
||||
* /privacy-policy → privacyPolicy()
|
||||
* /terms-of-service → termsOfService()
|
||||
*/
|
||||
final class FooterController extends Controller
|
||||
{
|
||||
public function faq(): View
|
||||
{
|
||||
return view('web.faq', [
|
||||
'page_title' => 'FAQ — Skinbase',
|
||||
'page_meta_description' => 'Frequently Asked Questions about Skinbase — the community for skins, wallpapers, and photography.',
|
||||
'page_canonical' => url('/faq'),
|
||||
'hero_title' => 'Frequently Asked Questions',
|
||||
'hero_description' => 'Answers to the most common questions from our members. Last updated March 1, 2026.',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Home', 'url' => '/'],
|
||||
(object) ['name' => 'FAQ', 'url' => '/faq'],
|
||||
]),
|
||||
'center_content' => true,
|
||||
'center_max' => '3xl',
|
||||
]);
|
||||
}
|
||||
|
||||
public function rules(): View
|
||||
{
|
||||
return view('web.rules', [
|
||||
'page_title' => 'Rules & Guidelines — Skinbase',
|
||||
'page_meta_description' => 'Read the Skinbase community rules and content guidelines before submitting your work.',
|
||||
'page_canonical' => url('/rules-and-guidelines'),
|
||||
'hero_title' => 'Rules & Guidelines',
|
||||
'hero_description' => 'Please review these guidelines before uploading or participating. Last updated March 1, 2026.',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Home', 'url' => '/'],
|
||||
(object) ['name' => 'Rules & Guidelines', 'url' => '/rules-and-guidelines'],
|
||||
]),
|
||||
'center_content' => true,
|
||||
'center_max' => '3xl',
|
||||
]);
|
||||
}
|
||||
|
||||
public function termsOfService(): View
|
||||
{
|
||||
return view('web.terms-of-service', [
|
||||
'page_title' => 'Terms of Service — Skinbase',
|
||||
'page_meta_description' => 'Read the Skinbase Terms of Service — the agreement that governs your use of the platform.',
|
||||
'page_canonical' => url('/terms-of-service'),
|
||||
'hero_title' => 'Terms of Service',
|
||||
'hero_description' => 'The agreement between you and Skinbase that governs your use of the platform. Last updated March 1, 2026.',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Home', 'url' => '/'],
|
||||
(object) ['name' => 'Terms of Service', 'url' => '/terms-of-service'],
|
||||
]),
|
||||
'center_content' => true,
|
||||
'center_max' => '3xl',
|
||||
]);
|
||||
}
|
||||
|
||||
public function privacyPolicy(): View
|
||||
{
|
||||
return view('web.privacy-policy', [
|
||||
'page_title' => 'Privacy Policy — Skinbase',
|
||||
'page_meta_description' => 'Read the Skinbase privacy policy to understand how we collect and use your data.',
|
||||
'page_canonical' => url('/privacy-policy'),
|
||||
'hero_title' => 'Privacy Policy',
|
||||
'hero_description' => 'How Skinbase collects, uses, and protects your information. Last updated March 1, 2026.',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Home', 'url' => '/'],
|
||||
(object) ['name' => 'Privacy Policy', 'url' => '/privacy-policy'],
|
||||
]),
|
||||
'center_content' => true,
|
||||
'center_max' => '3xl',
|
||||
]);
|
||||
}
|
||||
}
|
||||
75
app/Http/Controllers/Web/PageController.php
Normal file
75
app/Http/Controllers/Web/PageController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Page;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* PageController — DB-driven static pages (/pages/:slug).
|
||||
*
|
||||
* Also handles root-level marketing pages (/about, /help, /contact)
|
||||
* and legal pages (/legal/terms, /legal/privacy, /legal/cookies).
|
||||
*/
|
||||
final class PageController extends Controller
|
||||
{
|
||||
public function show(string $slug): View
|
||||
{
|
||||
$page = Page::published()->where('slug', $slug)->firstOrFail();
|
||||
|
||||
return view('web.pages.show', [
|
||||
'page' => $page,
|
||||
'page_title' => ($page->meta_title ?: $page->title) . ' — Skinbase',
|
||||
'page_meta_description' => $page->meta_description ?: '',
|
||||
'page_canonical' => $page->canonical_url,
|
||||
'page_robots' => 'index,follow',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => $page->title, 'url' => $page->url],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve root-level marketing slugs (/about, /help, /contact).
|
||||
* Falls back to 404 if no matching page exists.
|
||||
*/
|
||||
public function marketing(string $slug): View
|
||||
{
|
||||
$page = Page::published()->where('slug', $slug)->firstOrFail();
|
||||
|
||||
return view('web.pages.show', [
|
||||
'page' => $page,
|
||||
'page_title' => ($page->meta_title ?: $page->title) . ' — Skinbase',
|
||||
'page_meta_description' => $page->meta_description ?: '',
|
||||
'page_canonical' => url('/' . $slug),
|
||||
'page_robots' => 'index,follow',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => $page->title, 'url' => '/' . $slug],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legal pages (/legal/terms, /legal/privacy, /legal/cookies).
|
||||
* Looks for page with slug "legal-{section}".
|
||||
*/
|
||||
public function legal(string $section): View
|
||||
{
|
||||
$page = Page::published()->where('slug', 'legal-' . $section)->firstOrFail();
|
||||
|
||||
return view('web.pages.show', [
|
||||
'page' => $page,
|
||||
'page_title' => ($page->meta_title ?: $page->title) . ' — Skinbase',
|
||||
'page_meta_description' => $page->meta_description ?: '',
|
||||
'page_canonical' => url('/legal/' . $section),
|
||||
'page_robots' => 'index,follow',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Legal', 'url' => '#'],
|
||||
(object) ['name' => $page->title, 'url' => '/legal/' . $section],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
30
app/Http/Controllers/Web/Posts/FollowingFeedController.php
Normal file
30
app/Http/Controllers/Web/Posts/FollowingFeedController.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class FollowingFeedController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /feed/following
|
||||
* Renders the Following Feed Inertia page.
|
||||
* Actual data is loaded client-side via GET /api/posts/following
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Feed/FollowingFeed', [
|
||||
'auth' => [
|
||||
'user' => $request->user() ? [
|
||||
'id' => $request->user()->id,
|
||||
'username' => $request->user()->username,
|
||||
'name' => $request->user()->name,
|
||||
'avatar' => $request->user()->profile?->avatar_url ?? null,
|
||||
] : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
27
app/Http/Controllers/Web/Posts/HashtagFeedController.php
Normal file
27
app/Http/Controllers/Web/Posts/HashtagFeedController.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class HashtagFeedController extends Controller
|
||||
{
|
||||
/** GET /tags/{tag} */
|
||||
public function index(Request $request, string $tag): Response
|
||||
{
|
||||
return Inertia::render('Feed/HashtagFeed', [
|
||||
'auth' => $request->user() ? [
|
||||
'user' => [
|
||||
'id' => $request->user()->id,
|
||||
'username' => $request->user()->username,
|
||||
'name' => $request->user()->name,
|
||||
'avatar' => $request->user()->profile?->avatar_url ?? null,
|
||||
],
|
||||
] : null,
|
||||
'tag' => strtolower($tag),
|
||||
]);
|
||||
}
|
||||
}
|
||||
26
app/Http/Controllers/Web/Posts/SavedFeedController.php
Normal file
26
app/Http/Controllers/Web/Posts/SavedFeedController.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class SavedFeedController extends Controller
|
||||
{
|
||||
/** GET /feed/saved */
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return Inertia::render('Feed/SavedFeed', [
|
||||
'auth' => [
|
||||
'user' => [
|
||||
'id' => $request->user()->id,
|
||||
'username' => $request->user()->username,
|
||||
'name' => $request->user()->name,
|
||||
'avatar' => $request->user()->profile?->avatar_url ?? null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
38
app/Http/Controllers/Web/Posts/SearchFeedController.php
Normal file
38
app/Http/Controllers/Web/Posts/SearchFeedController.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Posts\PostHashtagService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class SearchFeedController extends Controller
|
||||
{
|
||||
public function __construct(private PostHashtagService $hashtagService) {}
|
||||
|
||||
/** GET /feed/search */
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$trendingHashtags = Cache::remember(
|
||||
'trending_hashtags',
|
||||
300,
|
||||
fn () => $this->hashtagService->trending(10, 24)
|
||||
);
|
||||
|
||||
return Inertia::render('Feed/SearchFeed', [
|
||||
'auth' => $request->user() ? [
|
||||
'user' => [
|
||||
'id' => $request->user()->id,
|
||||
'username' => $request->user()->username,
|
||||
'name' => $request->user()->name,
|
||||
'avatar' => $request->user()->profile?->avatar_url ?? null,
|
||||
],
|
||||
] : null,
|
||||
'initialQuery' => $request->query('q', ''),
|
||||
'trendingHashtags' => $trendingHashtags,
|
||||
]);
|
||||
}
|
||||
}
|
||||
33
app/Http/Controllers/Web/Posts/TrendingFeedController.php
Normal file
33
app/Http/Controllers/Web/Posts/TrendingFeedController.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Posts;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Posts\PostHashtagService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class TrendingFeedController extends Controller
|
||||
{
|
||||
public function __construct(private PostHashtagService $hashtagService) {}
|
||||
|
||||
/** GET /feed/trending */
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$trendingHashtags = Cache::remember('trending_hashtags', 300, fn () => $this->hashtagService->trending(10, 24));
|
||||
|
||||
return Inertia::render('Feed/TrendingFeed', [
|
||||
'auth' => $request->user() ? [
|
||||
'user' => [
|
||||
'id' => $request->user()->id,
|
||||
'username' => $request->user()->username,
|
||||
'name' => $request->user()->name,
|
||||
'avatar' => $request->user()->profile?->avatar_url ?? null,
|
||||
],
|
||||
] : null,
|
||||
'trendingHashtags' => $trendingHashtags,
|
||||
]);
|
||||
}
|
||||
}
|
||||
163
app/Http/Controllers/Web/RssFeedController.php
Normal file
163
app/Http/Controllers/Web/RssFeedController.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ContentType;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* RssFeedController
|
||||
*
|
||||
* GET /rss-feeds → info page listing all available feeds
|
||||
* GET /rss/latest-uploads.xml → all published artworks (legacy)
|
||||
* GET /rss/latest-skins.xml → skins only (legacy)
|
||||
* GET /rss/latest-wallpapers.xml → wallpapers only (legacy)
|
||||
* GET /rss/latest-photos.xml → photography only (legacy)
|
||||
*
|
||||
* Nova feeds live in App\Http\Controllers\RSS\*.
|
||||
*/
|
||||
final class RssFeedController extends Controller
|
||||
{
|
||||
/** Number of items per legacy feed. */
|
||||
private const FEED_LIMIT = 25;
|
||||
|
||||
/**
|
||||
* Grouped feed definitions shown on the /rss-feeds info page.
|
||||
* Each group has a 'label' and an array of 'feeds' with title + url.
|
||||
*/
|
||||
public const FEED_GROUPS = [
|
||||
'global' => [
|
||||
'label' => 'Global',
|
||||
'feeds' => [
|
||||
['title' => 'Latest Artworks', 'url' => '/rss', 'description' => 'All new artworks across the platform.'],
|
||||
],
|
||||
],
|
||||
'discover' => [
|
||||
'label' => 'Discover',
|
||||
'feeds' => [
|
||||
['title' => 'Fresh Uploads', 'url' => '/rss/discover/fresh', 'description' => 'The newest artworks just published.'],
|
||||
['title' => 'Trending', 'url' => '/rss/discover/trending', 'description' => 'Most-viewed artworks over the past 7 days.'],
|
||||
['title' => 'Rising', 'url' => '/rss/discover/rising', 'description' => 'Artworks gaining momentum right now.'],
|
||||
],
|
||||
],
|
||||
'explore' => [
|
||||
'label' => 'Explore',
|
||||
'feeds' => [
|
||||
['title' => 'All Artworks', 'url' => '/rss/explore/artworks', 'description' => 'Latest artworks of all types.'],
|
||||
['title' => 'Wallpapers', 'url' => '/rss/explore/wallpapers', 'description' => 'Latest wallpapers.'],
|
||||
['title' => 'Skins', 'url' => '/rss/explore/skins', 'description' => 'Latest skins.'],
|
||||
['title' => 'Photography', 'url' => '/rss/explore/photography', 'description' => 'Latest photography.'],
|
||||
['title' => 'Trending Wallpapers', 'url' => '/rss/explore/wallpapers/trending', 'description' => 'Trending wallpapers this week.'],
|
||||
],
|
||||
],
|
||||
'blog' => [
|
||||
'label' => 'Blog',
|
||||
'feeds' => [
|
||||
['title' => 'Blog Posts', 'url' => '/rss/blog', 'description' => 'Latest posts from the Skinbase blog.'],
|
||||
],
|
||||
],
|
||||
'legacy' => [
|
||||
'label' => 'Legacy Feeds',
|
||||
'feeds' => [
|
||||
['title' => 'Latest Uploads (XML)', 'url' => '/rss/latest-uploads.xml', 'description' => 'Legacy XML feed.'],
|
||||
['title' => 'Latest Skins (XML)', 'url' => '/rss/latest-skins.xml', 'description' => 'Legacy XML feed.'],
|
||||
['title' => 'Latest Wallpapers (XML)', 'url' => '/rss/latest-wallpapers.xml', 'description' => 'Legacy XML feed.'],
|
||||
['title' => 'Latest Photos (XML)', 'url' => '/rss/latest-photos.xml', 'description' => 'Legacy XML feed.'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/** Flat feed list kept for backward-compatibility (old view logic). */
|
||||
public const FEEDS = [
|
||||
'uploads' => ['title' => 'Latest Uploads', 'url' => '/rss/latest-uploads.xml'],
|
||||
'skins' => ['title' => 'Latest Skins', 'url' => '/rss/latest-skins.xml'],
|
||||
'wallpapers' => ['title' => 'Latest Wallpapers', 'url' => '/rss/latest-wallpapers.xml'],
|
||||
'photos' => ['title' => 'Latest Photos', 'url' => '/rss/latest-photos.xml'],
|
||||
];
|
||||
|
||||
/** Info page at /rss-feeds */
|
||||
public function index(): View
|
||||
{
|
||||
return view('web.rss-feeds', [
|
||||
'page_title' => 'RSS Feeds — Skinbase',
|
||||
'page_meta_description' => 'Subscribe to Skinbase RSS feeds to stay up to date with the latest uploads, skins, wallpapers, and photos.',
|
||||
'page_canonical' => url('/rss-feeds'),
|
||||
'hero_title' => 'RSS Feeds',
|
||||
'hero_description' => 'Subscribe to stay up to date with the latest content on Skinbase.',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Home', 'url' => '/'],
|
||||
(object) ['name' => 'RSS Feeds', 'url' => '/rss-feeds'],
|
||||
]),
|
||||
'feeds' => self::FEEDS,
|
||||
'feed_groups' => self::FEED_GROUPS,
|
||||
'center_content' => true,
|
||||
'center_max' => '3xl',
|
||||
]);
|
||||
}
|
||||
|
||||
/** /rss/latest-uploads.xml — all content types */
|
||||
public function latestUploads(): Response
|
||||
{
|
||||
$artworks = Artwork::published()
|
||||
->with(['user'])
|
||||
->latest('published_at')
|
||||
->limit(self::FEED_LIMIT)
|
||||
->get();
|
||||
|
||||
return $this->buildFeed('Latest Uploads', url('/rss/latest-uploads.xml'), $artworks);
|
||||
}
|
||||
|
||||
/** /rss/latest-skins.xml */
|
||||
public function latestSkins(): Response
|
||||
{
|
||||
return $this->feedByContentType('skins', 'Latest Skins', '/rss/latest-skins.xml');
|
||||
}
|
||||
|
||||
/** /rss/latest-wallpapers.xml */
|
||||
public function latestWallpapers(): Response
|
||||
{
|
||||
return $this->feedByContentType('wallpapers', 'Latest Wallpapers', '/rss/latest-wallpapers.xml');
|
||||
}
|
||||
|
||||
/** /rss/latest-photos.xml */
|
||||
public function latestPhotos(): Response
|
||||
{
|
||||
return $this->feedByContentType('photography', 'Latest Photos', '/rss/latest-photos.xml');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function feedByContentType(string $slug, string $title, string $feedPath): Response
|
||||
{
|
||||
$contentType = ContentType::where('slug', $slug)->first();
|
||||
|
||||
$query = Artwork::published()->with(['user'])->latest('published_at')->limit(self::FEED_LIMIT);
|
||||
|
||||
if ($contentType) {
|
||||
$query->whereHas('categories', fn ($q) => $q->where('content_type_id', $contentType->id));
|
||||
}
|
||||
|
||||
return $this->buildFeed($title, url($feedPath), $query->get());
|
||||
}
|
||||
|
||||
private function buildFeed(string $channelTitle, string $feedUrl, $artworks): Response
|
||||
{
|
||||
$content = view('rss.feed', [
|
||||
'channelTitle' => $channelTitle . ' — Skinbase',
|
||||
'channelDescription' => 'The latest ' . strtolower($channelTitle) . ' from Skinbase.org',
|
||||
'channelLink' => url('/'),
|
||||
'feedUrl' => $feedUrl,
|
||||
'artworks' => $artworks,
|
||||
'buildDate' => now()->toRfc2822String(),
|
||||
])->render();
|
||||
|
||||
return response($content, 200, [
|
||||
'Content-Type' => 'application/rss+xml; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
}
|
||||
21
app/Http/Controllers/Web/StaffApplicationAdminController.php
Normal file
21
app/Http/Controllers/Web/StaffApplicationAdminController.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?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]);
|
||||
}
|
||||
}
|
||||
52
app/Http/Controllers/Web/StaffController.php
Normal file
52
app/Http/Controllers/Web/StaffController.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* StaffController — /staff
|
||||
*
|
||||
* Displays all users with an elevated role (admin, moderator) grouped by role.
|
||||
*/
|
||||
final class StaffController extends Controller
|
||||
{
|
||||
/** Roles that appear on the staff page, in display order. */
|
||||
private const STAFF_ROLES = ['admin', 'moderator'];
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
/** @var Collection<string, \Illuminate\Support\Collection<int, User>> $staffByRole */
|
||||
$staffByRole = User::with('profile')
|
||||
->whereIn('role', self::STAFF_ROLES)
|
||||
->where('is_active', true)
|
||||
->orderByRaw("CASE role WHEN 'admin' THEN 0 WHEN 'moderator' THEN 1 ELSE 2 END")
|
||||
->orderBy('username')
|
||||
->get()
|
||||
->groupBy('role');
|
||||
|
||||
return view('web.staff', [
|
||||
'page_title' => 'Staff — Skinbase',
|
||||
'page_meta_description' => 'Meet the Skinbase team — admins and moderators who keep the community running.',
|
||||
'page_canonical' => url('/staff'),
|
||||
'hero_title' => 'Meet the Staff',
|
||||
'hero_description' => 'The people behind Skinbase who keep the community running smoothly.',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Home', 'url' => '/'],
|
||||
(object) ['name' => 'Staff', 'url' => '/staff'],
|
||||
]),
|
||||
'staffByRole' => $staffByRole,
|
||||
'roleLabels' => [
|
||||
'admin' => 'Administrators',
|
||||
'moderator' => 'Moderators',
|
||||
],
|
||||
'center_content' => true,
|
||||
'center_max' => '3xl',
|
||||
]);
|
||||
}
|
||||
}
|
||||
59
app/Http/Controllers/Web/StoriesAuthorController.php
Normal file
59
app/Http/Controllers/Web/StoriesAuthorController.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryAuthor;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Stories filtered by author — /stories/author/{username}
|
||||
*/
|
||||
final class StoriesAuthorController extends Controller
|
||||
{
|
||||
public function show(Request $request, string $username): View
|
||||
{
|
||||
// Resolve by linked user username first, then by author name slug
|
||||
$author = StoryAuthor::whereHas('user', fn ($q) => $q->where('username', $username))
|
||||
->with('user')
|
||||
->first();
|
||||
|
||||
if (! $author) {
|
||||
// Fallback: author name matches slug-style
|
||||
$author = StoryAuthor::where('name', $username)->first();
|
||||
}
|
||||
|
||||
if (! $author) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$stories = Cache::remember('stories:author:' . $author->id . ':page:' . ($request->get('page', 1)), 300, fn () =>
|
||||
Story::published()
|
||||
->with('author', 'tags')
|
||||
->where('author_id', $author->id)
|
||||
->orderByDesc('published_at')
|
||||
->paginate(12)
|
||||
->withQueryString()
|
||||
);
|
||||
|
||||
$authorName = $author->user?->username ?? $author->name;
|
||||
|
||||
return view('web.stories.author', [
|
||||
'author' => $author,
|
||||
'stories' => $stories,
|
||||
'page_title' => 'Stories by ' . $authorName . ' — Skinbase',
|
||||
'page_meta_description' => 'All stories and interviews by ' . $authorName . ' on Skinbase.',
|
||||
'page_canonical' => url('/stories/author/' . $username),
|
||||
'page_robots' => 'index,follow',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Stories', 'url' => '/stories'],
|
||||
(object) ['name' => $authorName, 'url' => '/stories/author/' . $username],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
47
app/Http/Controllers/Web/StoriesController.php
Normal file
47
app/Http/Controllers/Web/StoriesController.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Story;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Stories listing page — /stories
|
||||
*/
|
||||
final class StoriesController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$featured = Cache::remember('stories:featured', 300, fn () =>
|
||||
Story::published()->featured()
|
||||
->with('author', 'tags')
|
||||
->orderByDesc('published_at')
|
||||
->first()
|
||||
);
|
||||
|
||||
$stories = Cache::remember('stories:list:page:' . ($request->get('page', 1)), 300, fn () =>
|
||||
Story::published()
|
||||
->with('author', 'tags')
|
||||
->orderByDesc('published_at')
|
||||
->paginate(12)
|
||||
->withQueryString()
|
||||
);
|
||||
|
||||
return view('web.stories.index', [
|
||||
'featured' => $featured,
|
||||
'stories' => $stories,
|
||||
'page_title' => 'Stories — Skinbase',
|
||||
'page_meta_description' => 'Artist interviews, community spotlights, tutorials and announcements from Skinbase.',
|
||||
'page_canonical' => url('/stories'),
|
||||
'page_robots' => 'index,follow',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Stories', 'url' => '/stories'],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
45
app/Http/Controllers/Web/StoriesTagController.php
Normal file
45
app/Http/Controllers/Web/StoriesTagController.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryTag;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Stories filtered by tag — /stories/tag/{tag}
|
||||
*/
|
||||
final class StoriesTagController extends Controller
|
||||
{
|
||||
public function show(Request $request, string $tag): View
|
||||
{
|
||||
$storyTag = StoryTag::where('slug', $tag)->firstOrFail();
|
||||
|
||||
$stories = Cache::remember('stories:tag:' . $tag . ':page:' . ($request->get('page', 1)), 300, fn () =>
|
||||
Story::published()
|
||||
->with('author', 'tags')
|
||||
->whereHas('tags', fn ($q) => $q->where('stories_tags.id', $storyTag->id))
|
||||
->orderByDesc('published_at')
|
||||
->paginate(12)
|
||||
->withQueryString()
|
||||
);
|
||||
|
||||
return view('web.stories.tag', [
|
||||
'storyTag' => $storyTag,
|
||||
'stories' => $stories,
|
||||
'page_title' => '#' . $storyTag->name . ' Stories — Skinbase',
|
||||
'page_meta_description' => 'Stories tagged with "' . $storyTag->name . '" on Skinbase.',
|
||||
'page_canonical' => url('/stories/tag/' . $storyTag->slug),
|
||||
'page_robots' => 'index,follow',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Stories', 'url' => '/stories'],
|
||||
(object) ['name' => '#' . $storyTag->name, 'url' => '/stories/tag/' . $storyTag->slug],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
86
app/Http/Controllers/Web/StoryController.php
Normal file
86
app/Http/Controllers/Web/StoryController.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Story;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Single story page — /stories/{slug}
|
||||
*/
|
||||
final class StoryController extends Controller
|
||||
{
|
||||
public function show(string $slug): View
|
||||
{
|
||||
$story = Cache::remember('stories:' . $slug, 600, fn () =>
|
||||
Story::published()
|
||||
->with('author', 'tags')
|
||||
->where('slug', $slug)
|
||||
->firstOrFail()
|
||||
);
|
||||
|
||||
// Increment view counter (fire-and-forget, no cache invalidation needed)
|
||||
Story::where('id', $story->id)->increment('views');
|
||||
|
||||
// Related stories: shared tags → same author → newest
|
||||
$related = Cache::remember('stories:related:' . $story->id, 600, function () use ($story) {
|
||||
$tagIds = $story->tags->pluck('id');
|
||||
|
||||
$related = collect();
|
||||
|
||||
if ($tagIds->isNotEmpty()) {
|
||||
$related = Story::published()
|
||||
->with('author', 'tags')
|
||||
->whereHas('tags', fn ($q) => $q->whereIn('stories_tags.id', $tagIds))
|
||||
->where('id', '!=', $story->id)
|
||||
->orderByDesc('published_at')
|
||||
->limit(6)
|
||||
->get();
|
||||
}
|
||||
|
||||
if ($related->count() < 3 && $story->author_id) {
|
||||
$byAuthor = Story::published()
|
||||
->with('author', 'tags')
|
||||
->where('author_id', $story->author_id)
|
||||
->where('id', '!=', $story->id)
|
||||
->whereNotIn('id', $related->pluck('id'))
|
||||
->orderByDesc('published_at')
|
||||
->limit(6 - $related->count())
|
||||
->get();
|
||||
|
||||
$related = $related->merge($byAuthor);
|
||||
}
|
||||
|
||||
if ($related->count() < 3) {
|
||||
$newest = Story::published()
|
||||
->with('author', 'tags')
|
||||
->where('id', '!=', $story->id)
|
||||
->whereNotIn('id', $related->pluck('id'))
|
||||
->orderByDesc('published_at')
|
||||
->limit(6 - $related->count())
|
||||
->get();
|
||||
|
||||
$related = $related->merge($newest);
|
||||
}
|
||||
|
||||
return $related->take(6);
|
||||
});
|
||||
|
||||
return view('web.stories.show', [
|
||||
'story' => $story,
|
||||
'related' => $related,
|
||||
'page_title' => $story->title . ' — Skinbase Stories',
|
||||
'page_meta_description' => $story->meta_excerpt,
|
||||
'page_canonical' => $story->url,
|
||||
'page_robots' => 'index,follow',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Stories', 'url' => '/stories'],
|
||||
(object) ['name' => $story->title, 'url' => $story->url],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,17 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\Tag;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\EarlyGrowth\GridFiller;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
final class TagController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ArtworkSearchService $search) {}
|
||||
public function __construct(
|
||||
private readonly ArtworkSearchService $search,
|
||||
private readonly GridFiller $gridFiller,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
@@ -52,11 +57,14 @@ final class TagController extends Controller
|
||||
->paginate($perPage)
|
||||
->appends(['sort' => $sort]);
|
||||
|
||||
// Eager-load relations needed by the artwork-card component.
|
||||
// Scout returns bare Eloquent models; without this, each card triggers N+1 queries.
|
||||
$artworks->getCollection()->loadMissing(['user.profile']);
|
||||
// EGS: ensure tag pages never show a half-empty grid on page 1
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$artworks = $this->gridFiller->fill($artworks, 0, $page);
|
||||
|
||||
// Sidebar: content type links (same as browse gallery)
|
||||
// Eager-load relations used by the gallery presenter and thumbnails.
|
||||
$artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories']));
|
||||
|
||||
// Sidebar: main content type links (same as browse gallery)
|
||||
$mainCategories = ContentType::orderBy('id')->get(['name', 'slug'])
|
||||
->map(fn ($type) => (object) [
|
||||
'id' => $type->id,
|
||||
@@ -65,6 +73,48 @@ final class TagController extends Controller
|
||||
'url' => '/' . strtolower($type->slug),
|
||||
]);
|
||||
|
||||
// Map artworks into the lightweight shape expected by the gallery React component.
|
||||
$galleryCollection = $artworks->getCollection()->map(function ($a) {
|
||||
$primaryCategory = $a->categories->sortBy('sort_order')->first();
|
||||
$present = ThumbnailPresenter::present($a, 'md');
|
||||
$avatarUrl = \App\Support\AvatarUrl::forUser((int) ($a->user_id ?? 0), $a->user?->profile?->avatar_hash ?? null, 64);
|
||||
|
||||
return (object) [
|
||||
'id' => $a->id,
|
||||
'name' => $a->title ?? ($a->name ?? null),
|
||||
'category_name' => $primaryCategory->name ?? '',
|
||||
'category_slug' => $primaryCategory->slug ?? '',
|
||||
'thumb_url' => $present['url'] ?? ($a->thumbUrl('md') ?? null),
|
||||
'thumb_srcset' => $present['srcset'] ?? null,
|
||||
'uname' => $a->user?->name ?? '',
|
||||
'username' => $a->user?->username ?? '',
|
||||
'avatar_url' => $avatarUrl,
|
||||
'published_at' => $a->published_at ?? null,
|
||||
'width' => $a->width ?? null,
|
||||
'height' => $a->height ?? null,
|
||||
'slug' => $a->slug ?? null,
|
||||
];
|
||||
})->values();
|
||||
|
||||
// Replace paginator collection with the gallery-shaped collection so
|
||||
// the gallery.index blade will generate the expected JSON payload.
|
||||
if (method_exists($artworks, 'setCollection')) {
|
||||
$artworks->setCollection($galleryCollection);
|
||||
}
|
||||
|
||||
// Determine gallery sort mapping so the gallery UI highlights the right tab.
|
||||
$sortMapToGallery = [
|
||||
'popular' => 'trending',
|
||||
'latest' => 'latest',
|
||||
'likes' => 'top-rated',
|
||||
'downloads' => 'downloaded',
|
||||
];
|
||||
$gallerySort = $sortMapToGallery[$sort] ?? 'trending';
|
||||
|
||||
// Build simple pagination SEO links
|
||||
$prev = method_exists($artworks, 'previousPageUrl') ? $artworks->previousPageUrl() : null;
|
||||
$next = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
|
||||
|
||||
return view('gallery.index', [
|
||||
'gallery_type' => 'tag',
|
||||
'mainCategories' => $mainCategories,
|
||||
@@ -72,12 +122,26 @@ final class TagController extends Controller
|
||||
'contentType' => null,
|
||||
'category' => null,
|
||||
'artworks' => $artworks,
|
||||
'hero_title' => '#' . $tag->name,
|
||||
'hero_description'=> 'All artworks tagged "' . e($tag->name) . '".',
|
||||
'breadcrumbs' => collect(),
|
||||
'current_sort' => $gallerySort,
|
||||
'sort_options' => [
|
||||
['value' => 'trending', 'label' => '🔥 Trending'],
|
||||
['value' => 'fresh', 'label' => '🆕 New & Hot'],
|
||||
['value' => 'top-rated', 'label' => '⭐ Top Rated'],
|
||||
['value' => 'latest', 'label' => '🕐 Latest'],
|
||||
],
|
||||
'hero_title' => $tag->name,
|
||||
'hero_description' => 'Artworks tagged "' . $tag->name . '"',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Home', 'url' => '/'],
|
||||
(object) ['name' => 'Tags', 'url' => route('tags.index')],
|
||||
(object) ['name' => $tag->name, 'url' => route('tags.show', $tag->slug)],
|
||||
]),
|
||||
'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase',
|
||||
'page_meta_description' => 'Browse all Skinbase artworks tagged "' . $tag->name . '". Discover photography, wallpapers and skins.',
|
||||
'page_meta_description' => 'Browse all Skinbase artworks tagged "' . $tag->name . '".',
|
||||
'page_meta_keywords' => $tag->slug . ', skinbase, artworks, tag',
|
||||
'page_canonical' => route('tags.show', $tag->slug),
|
||||
'page_rel_prev' => $prev,
|
||||
'page_rel_next' => $next,
|
||||
'page_robots' => 'index,follow',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,18 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureOnboardingComplete
|
||||
{
|
||||
/**
|
||||
* Paths that must always be reachable regardless of onboarding state,
|
||||
* so authenticated users can log out, complete OAuth flows, etc.
|
||||
*/
|
||||
private const ALWAYS_ALLOW = [
|
||||
'logout',
|
||||
'auth/*', // OAuth redirects & callbacks
|
||||
'verify/*', // email verification links
|
||||
'setup/*', // all /setup/* pages (password, username)
|
||||
'up', // health check
|
||||
];
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
@@ -20,6 +32,11 @@ class EnsureOnboardingComplete
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Always allow critical auth / setup paths through.
|
||||
if ($request->is(self::ALWAYS_ALLOW)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$target = match ($step) {
|
||||
'email' => '/login',
|
||||
'verified' => '/setup/password',
|
||||
@@ -27,10 +44,6 @@ class EnsureOnboardingComplete
|
||||
default => '/setup/password',
|
||||
};
|
||||
|
||||
if ($request->is(ltrim($target, '/'))) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return redirect($target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,41 @@ final class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
protected $rootView = 'upload';
|
||||
|
||||
/**
|
||||
* Select the root Blade view based on route prefix.
|
||||
*/
|
||||
public function rootView(Request $request): string
|
||||
{
|
||||
if (str_starts_with($request->path(), 'studio')) {
|
||||
return 'studio';
|
||||
}
|
||||
|
||||
// Profile pages: /@{username}
|
||||
if (str_starts_with($request->path(), '@')) {
|
||||
return 'profile.show';
|
||||
}
|
||||
|
||||
// Feed pages — ordered most-specific first
|
||||
if ($request->path() === 'feed/trending') {
|
||||
return 'feed.trending';
|
||||
}
|
||||
|
||||
if ($request->path() === 'feed/saved') {
|
||||
return 'feed.saved';
|
||||
}
|
||||
|
||||
if (str_starts_with($request->path(), 'feed')) {
|
||||
return 'feed.following';
|
||||
}
|
||||
|
||||
// Hashtag pages: /tags/{tag}
|
||||
if (str_starts_with($request->path(), 'tags/')) {
|
||||
return 'feed.hashtag';
|
||||
}
|
||||
|
||||
return $this->rootView;
|
||||
}
|
||||
|
||||
public function version(Request $request): ?string
|
||||
{
|
||||
return parent::version($request);
|
||||
|
||||
@@ -14,5 +14,6 @@ class VerifyCsrfToken extends Middleware
|
||||
protected $except = [
|
||||
'chat_post',
|
||||
'chat_post/*',
|
||||
// Apple Sign In removed — no special CSRF exception required
|
||||
];
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ final class ArtworkCreateRequest extends FormRequest
|
||||
return [
|
||||
'title' => 'required|string|max:150',
|
||||
'description' => 'nullable|string',
|
||||
'category' => 'nullable|string|max:120',
|
||||
'category' => 'nullable|integer|exists:categories,id',
|
||||
'tags' => 'nullable|string|max:200',
|
||||
'license' => 'nullable|boolean',
|
||||
];
|
||||
|
||||
27
app/Http/Requests/Posts/CreateCommentRequest.php
Normal file
27
app/Http/Requests/Posts/CreateCommentRequest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Posts;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CreateCommentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return (bool) $this->user();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'body' => ['required', 'string', 'min:1', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'body.max' => 'Comment cannot exceed 1,000 characters.',
|
||||
];
|
||||
}
|
||||
}
|
||||
43
app/Http/Requests/Posts/CreatePostRequest.php
Normal file
43
app/Http/Requests/Posts/CreatePostRequest.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Posts;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CreatePostRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return (bool) $this->user();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', 'string', 'in:text,artwork_share,upload,achievement'],
|
||||
'visibility' => ['required', 'string', 'in:public,followers,private'],
|
||||
'body' => ['nullable', 'string', 'max:2000'],
|
||||
'targets' => ['nullable', 'array', 'max:1'],
|
||||
'targets.*.type' => ['required_with:targets', 'string', 'in:artwork'],
|
||||
'targets.*.id' => ['required_with:targets', 'integer', 'min:1'],
|
||||
'link_preview' => ['nullable', 'array'],
|
||||
'link_preview.url' => ['nullable', 'string', 'url', 'max:2048'],
|
||||
'link_preview.title' => ['nullable', 'string', 'max:300'],
|
||||
'link_preview.description' => ['nullable', 'string', 'max:500'],
|
||||
'link_preview.image' => ['nullable', 'string', 'url', 'max:2048'],
|
||||
'link_preview.site_name' => ['nullable', 'string', 'max:100'],
|
||||
'tagged_users' => ['nullable', 'array', 'max:10'],
|
||||
'tagged_users.*.id' => ['required_with:tagged_users', 'integer', 'min:1'],
|
||||
'tagged_users.*.username' => ['required_with:tagged_users', 'string', 'max:50'],
|
||||
'tagged_users.*.name' => ['nullable', 'string', 'max:100'],
|
||||
'publish_at' => ['nullable', 'date', 'after:now'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'body.max' => 'Post body cannot exceed 2,000 characters.',
|
||||
];
|
||||
}
|
||||
}
|
||||
21
app/Http/Requests/Posts/ShareArtworkRequest.php
Normal file
21
app/Http/Requests/Posts/ShareArtworkRequest.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Posts;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ShareArtworkRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return (bool) $this->user();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'body' => ['nullable', 'string', 'max:2000'],
|
||||
'visibility' => ['required', 'string', 'in:public,followers,private'],
|
||||
];
|
||||
}
|
||||
}
|
||||
21
app/Http/Requests/Posts/UpdatePostRequest.php
Normal file
21
app/Http/Requests/Posts/UpdatePostRequest.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Posts;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdatePostRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return (bool) $this->user();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'body' => ['nullable', 'string', 'max:2000'],
|
||||
'visibility' => ['nullable', 'string', 'in:public,followers,private'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ class ProfileUpdateRequest extends FormRequest
|
||||
'country' => ['nullable', 'string', 'max:10'],
|
||||
'mailing' => ['nullable', 'boolean'],
|
||||
'notify' => ['nullable', 'boolean'],
|
||||
'auto_post_upload' => ['nullable', 'boolean'],
|
||||
'about' => ['nullable', 'string'],
|
||||
'signature' => ['nullable', 'string'],
|
||||
'description' => ['nullable', 'string'],
|
||||
|
||||
@@ -78,6 +78,7 @@ final class UploadFinishRequest extends FormRequest
|
||||
'session_id' => 'required|uuid',
|
||||
'artwork_id' => 'required|integer',
|
||||
'upload_token' => 'nullable|string|min:40|max:200',
|
||||
'file_name' => 'nullable|string|max:255',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -23,13 +23,14 @@ final class GenerateDerivativesJob implements ShouldQueue
|
||||
public function __construct(
|
||||
private readonly string $sessionId,
|
||||
private readonly string $hash,
|
||||
private readonly int $artworkId
|
||||
private readonly int $artworkId,
|
||||
private readonly ?string $originalFileName = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(UploadPipelineService $pipeline): void
|
||||
{
|
||||
$pipeline->processAndPublish($this->sessionId, $this->hash, $this->artworkId);
|
||||
$pipeline->processAndPublish($this->sessionId, $this->hash, $this->artworkId, $this->originalFileName);
|
||||
|
||||
// Auto-tagging is async and must never block publish.
|
||||
AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit();
|
||||
|
||||
70
app/Jobs/Posts/AutoUploadPostJob.php
Normal file
70
app/Jobs/Posts/AutoUploadPostJob.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Posts;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostTarget;
|
||||
use App\Models\User;
|
||||
use App\Services\Posts\PostHashtagService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Creates a feed post of type=upload when an artwork is published.
|
||||
* Dispatched from ArtworkObserver when auto_post_upload is enabled for the user.
|
||||
*/
|
||||
class AutoUploadPostJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $artworkId,
|
||||
public readonly int $userId,
|
||||
) {}
|
||||
|
||||
public function handle(PostHashtagService $hashtagService): void
|
||||
{
|
||||
$artwork = Artwork::find($this->artworkId);
|
||||
$user = User::find($this->userId);
|
||||
|
||||
if (! $artwork || ! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If post already exists for this artwork, skip (idempotent)
|
||||
$exists = Post::where('user_id', $user->id)
|
||||
->where('type', Post::TYPE_UPLOAD)
|
||||
->whereHas('targets', fn ($q) => $q->where('target_type', 'artwork')->where('target_id', $artwork->id))
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($artwork, $user, $hashtagService) {
|
||||
$post = Post::create([
|
||||
'user_id' => $user->id,
|
||||
'type' => Post::TYPE_UPLOAD,
|
||||
'visibility' => Post::VISIBILITY_PUBLIC,
|
||||
'body' => null,
|
||||
'status' => Post::STATUS_PUBLISHED,
|
||||
]);
|
||||
|
||||
PostTarget::create([
|
||||
'post_id' => $post->id,
|
||||
'target_type' => 'artwork',
|
||||
'target_id' => $artwork->id,
|
||||
]);
|
||||
});
|
||||
|
||||
Log::info("AutoUploadPostJob: created upload post for artwork #{$this->artworkId} by user #{$this->userId}");
|
||||
}
|
||||
}
|
||||
148
app/Jobs/RecBuildItemPairsFromFavouritesJob.php
Normal file
148
app/Jobs/RecBuildItemPairsFromFavouritesJob.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Build item-item co-occurrence pairs from user favourites.
|
||||
*
|
||||
* Spec §7.1 — runs hourly or every few hours.
|
||||
* For each user: take last N favourites, create pairs, increment weights.
|
||||
*
|
||||
* Safety: limits per-user pairs to avoid O(n²) explosion.
|
||||
*/
|
||||
final class RecBuildItemPairsFromFavouritesJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 2;
|
||||
public int $timeout = 600;
|
||||
|
||||
public function __construct(
|
||||
private readonly int $userBatchSize = 500,
|
||||
) {
|
||||
$queue = (string) config('recommendations.queue', 'default');
|
||||
if ($queue !== '') {
|
||||
$this->onQueue($queue);
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$favCap = (int) config('recommendations.similarity.user_favourites_cap', 50);
|
||||
|
||||
// ── Pre-compute per-artwork total favourite counts for cosine normalization ──
|
||||
$this->artworkLikeCounts = DB::table('artwork_favourites')
|
||||
->select('artwork_id', DB::raw('COUNT(*) as cnt'))
|
||||
->groupBy('artwork_id')
|
||||
->pluck('cnt', 'artwork_id')
|
||||
->all();
|
||||
|
||||
// ── Accumulate co-occurrence counts across all users ──
|
||||
$coOccurrenceCounts = [];
|
||||
|
||||
DB::table('artwork_favourites')
|
||||
->select('user_id')
|
||||
->groupBy('user_id')
|
||||
->orderBy('user_id')
|
||||
->chunk($this->userBatchSize, function ($userRows) use ($favCap, &$coOccurrenceCounts) {
|
||||
foreach ($userRows as $row) {
|
||||
$pairs = $this->pairsForUser((int) $row->user_id, $favCap);
|
||||
foreach ($pairs as $pair) {
|
||||
$key = $pair[0] . ':' . $pair[1];
|
||||
$coOccurrenceCounts[$key] = ($coOccurrenceCounts[$key] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Normalize to cosine-like scores and flush ──
|
||||
$normalized = [];
|
||||
foreach ($coOccurrenceCounts as $key => $count) {
|
||||
[$a, $b] = explode(':', $key);
|
||||
$likesA = $this->artworkLikeCounts[(int) $a] ?? 1;
|
||||
$likesB = $this->artworkLikeCounts[(int) $b] ?? 1;
|
||||
$normalized[$key] = $count / sqrt($likesA * $likesB);
|
||||
}
|
||||
|
||||
$this->flushPairs($normalized);
|
||||
}
|
||||
|
||||
/** @var array<int, int> artwork_id => total favourite count */
|
||||
private array $artworkLikeCounts = [];
|
||||
|
||||
/**
|
||||
* Collect pairs from a single user's last N favourites.
|
||||
*
|
||||
* @return list<array{0: int, 1: int}>
|
||||
*/
|
||||
public function pairsForUser(int $userId, int $cap): array
|
||||
{
|
||||
$artworkIds = DB::table('artwork_favourites')
|
||||
->where('user_id', $userId)
|
||||
->orderByDesc('created_at')
|
||||
->limit($cap)
|
||||
->pluck('artwork_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
$count = count($artworkIds);
|
||||
if ($count < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pairs = [];
|
||||
// Cap max pairs per user to avoid explosion: C(50,2) = 1225 worst case = acceptable
|
||||
for ($i = 0; $i < $count - 1; $i++) {
|
||||
for ($j = $i + 1; $j < $count; $j++) {
|
||||
$a = min($artworkIds[$i], $artworkIds[$j]);
|
||||
$b = max($artworkIds[$i], $artworkIds[$j]);
|
||||
$pairs[] = [$a, $b];
|
||||
}
|
||||
}
|
||||
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert normalized pair weights into rec_item_pairs.
|
||||
*
|
||||
* Uses Laravel's DB-agnostic upsert (works on MySQL, Postgres, SQLite).
|
||||
*
|
||||
* @param array<string, float> $upserts key = "a:b", value = cosine-normalized weight
|
||||
*/
|
||||
private function flushPairs(array $upserts): void
|
||||
{
|
||||
if ($upserts === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
|
||||
foreach (array_chunk($upserts, 500, preserve_keys: true) as $chunk) {
|
||||
$rows = [];
|
||||
foreach ($chunk as $key => $weight) {
|
||||
[$a, $b] = explode(':', $key);
|
||||
$rows[] = [
|
||||
'a_artwork_id' => (int) $a,
|
||||
'b_artwork_id' => (int) $b,
|
||||
'weight' => $weight,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
DB::table('rec_item_pairs')->upsert(
|
||||
$rows,
|
||||
['a_artwork_id', 'b_artwork_id'],
|
||||
['weight', 'updated_at'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
129
app/Jobs/RecComputeSimilarByBehaviorJob.php
Normal file
129
app/Jobs/RecComputeSimilarByBehaviorJob.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\RecArtworkRec;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Compute behavior-based (co-like) similarity from precomputed item pairs.
|
||||
*
|
||||
* Spec §7.3 — runs nightly.
|
||||
* For each artwork: read top pairs from rec_item_pairs, store top N.
|
||||
*/
|
||||
final class RecComputeSimilarByBehaviorJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 2;
|
||||
public int $timeout = 600;
|
||||
|
||||
public function __construct(
|
||||
private readonly ?int $artworkId = null,
|
||||
private readonly int $batchSize = 200,
|
||||
) {
|
||||
$queue = (string) config('recommendations.queue', 'default');
|
||||
if ($queue !== '') {
|
||||
$this->onQueue($queue);
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1');
|
||||
$resultLimit = (int) config('recommendations.similarity.result_limit', 30);
|
||||
$maxPerAuthor = (int) config('recommendations.similarity.max_per_author', 2);
|
||||
|
||||
$query = Artwork::query()->public()->published()->select('id', 'user_id');
|
||||
|
||||
if ($this->artworkId !== null) {
|
||||
$query->where('id', $this->artworkId);
|
||||
}
|
||||
|
||||
$query->chunkById($this->batchSize, function ($artworks) use ($modelVersion, $resultLimit, $maxPerAuthor) {
|
||||
foreach ($artworks as $artwork) {
|
||||
$this->processArtwork($artwork, $modelVersion, $resultLimit, $maxPerAuthor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function processArtwork(
|
||||
Artwork $artwork,
|
||||
string $modelVersion,
|
||||
int $resultLimit,
|
||||
int $maxPerAuthor,
|
||||
): void {
|
||||
// Fetch top co-occurring artworks (bi-directional)
|
||||
$candidates = DB::table('rec_item_pairs')
|
||||
->where('a_artwork_id', $artwork->id)
|
||||
->select(DB::raw('b_artwork_id AS related_id'), 'weight')
|
||||
->union(
|
||||
DB::table('rec_item_pairs')
|
||||
->where('b_artwork_id', $artwork->id)
|
||||
->select(DB::raw('a_artwork_id AS related_id'), 'weight')
|
||||
)
|
||||
->orderByDesc('weight')
|
||||
->limit($resultLimit * 3)
|
||||
->get();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relatedIds = $candidates->pluck('related_id')->map(fn ($id) => (int) $id)->all();
|
||||
|
||||
// Fetch author info for diversity filtering
|
||||
$authorMap = DB::table('artworks')
|
||||
->whereIn('id', $relatedIds)
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', now())
|
||||
->whereNull('deleted_at')
|
||||
->pluck('user_id', 'id')
|
||||
->all();
|
||||
|
||||
// Apply diversity cap
|
||||
$authorCounts = [];
|
||||
$final = [];
|
||||
foreach ($candidates as $cand) {
|
||||
$relatedId = (int) $cand->related_id;
|
||||
if (! isset($authorMap[$relatedId])) {
|
||||
continue; // not public/published
|
||||
}
|
||||
$authorId = (int) $authorMap[$relatedId];
|
||||
$authorCounts[$authorId] = ($authorCounts[$authorId] ?? 0) + 1;
|
||||
if ($authorCounts[$authorId] > $maxPerAuthor) {
|
||||
continue;
|
||||
}
|
||||
$final[] = $relatedId;
|
||||
if (count($final) >= $resultLimit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($final === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
RecArtworkRec::query()->updateOrCreate(
|
||||
[
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_behavior',
|
||||
'model_version' => $modelVersion,
|
||||
],
|
||||
[
|
||||
'recs' => $final,
|
||||
'computed_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
225
app/Jobs/RecComputeSimilarByTagsJob.php
Normal file
225
app/Jobs/RecComputeSimilarByTagsJob.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\RecArtworkRec;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Compute tag-based (+ category boost) similarity for artworks.
|
||||
*
|
||||
* Spec §7.2 — runs nightly + on-demand.
|
||||
* For each artwork: find candidates by shared tags/category, score with IDF-weighted
|
||||
* tag overlap, apply diversity, store top N.
|
||||
*/
|
||||
final class RecComputeSimilarByTagsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 2;
|
||||
public int $timeout = 600;
|
||||
|
||||
public function __construct(
|
||||
private readonly ?int $artworkId = null,
|
||||
private readonly int $batchSize = 200,
|
||||
) {
|
||||
$queue = (string) config('recommendations.queue', 'default');
|
||||
if ($queue !== '') {
|
||||
$this->onQueue($queue);
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1');
|
||||
$candidatePool = (int) config('recommendations.similarity.candidate_pool', 100);
|
||||
$maxPerAuthor = (int) config('recommendations.similarity.max_per_author', 2);
|
||||
$resultLimit = (int) config('recommendations.similarity.result_limit', 30);
|
||||
|
||||
// ── Tag IDF weights (global) ───────────────────────────────────────────
|
||||
$tagFreqs = DB::table('artwork_tag')
|
||||
->select('tag_id', DB::raw('COUNT(*) as cnt'))
|
||||
->groupBy('tag_id')
|
||||
->pluck('cnt', 'tag_id')
|
||||
->all();
|
||||
|
||||
$query = Artwork::query()->public()->published()->select('id', 'user_id');
|
||||
|
||||
if ($this->artworkId !== null) {
|
||||
$query->where('id', $this->artworkId);
|
||||
}
|
||||
|
||||
$query->chunkById($this->batchSize, function ($artworks) use (
|
||||
$tagFreqs, $modelVersion, $candidatePool, $maxPerAuthor, $resultLimit
|
||||
) {
|
||||
foreach ($artworks as $artwork) {
|
||||
$this->processArtwork($artwork, $tagFreqs, $modelVersion, $candidatePool, $maxPerAuthor, $resultLimit);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function processArtwork(
|
||||
Artwork $artwork,
|
||||
array $tagFreqs,
|
||||
string $modelVersion,
|
||||
int $candidatePool,
|
||||
int $maxPerAuthor,
|
||||
int $resultLimit,
|
||||
): void {
|
||||
// Get source artwork's tags and categories
|
||||
$srcTagIds = DB::table('artwork_tag')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->pluck('tag_id')
|
||||
->all();
|
||||
|
||||
$srcCatIds = DB::table('artwork_category')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->pluck('category_id')
|
||||
->all();
|
||||
|
||||
// Source content_type_ids (via categories)
|
||||
$srcContentTypeIds = $srcCatIds !== []
|
||||
? DB::table('categories')
|
||||
->whereIn('id', $srcCatIds)
|
||||
->whereNotNull('content_type_id')
|
||||
->pluck('content_type_id')
|
||||
->unique()
|
||||
->all()
|
||||
: [];
|
||||
|
||||
if ($srcTagIds === [] && $srcCatIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Find candidates that share at least one tag ────────────────────────
|
||||
$candidateQuery = DB::table('artwork_tag')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_tag.artwork_id')
|
||||
->whereIn('artwork_tag.tag_id', $srcTagIds)
|
||||
->where('artwork_tag.artwork_id', '!=', $artwork->id)
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNotNull('artworks.published_at')
|
||||
->where('artworks.published_at', '<=', now())
|
||||
->whereNull('artworks.deleted_at')
|
||||
->select('artwork_tag.artwork_id', 'artworks.user_id')
|
||||
->groupBy('artwork_tag.artwork_id', 'artworks.user_id')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->limit($candidatePool * 3); // over-fetch before scoring
|
||||
|
||||
$candidates = $candidateQuery->get();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Gather tags for all candidates in one query
|
||||
$candidateIds = $candidates->pluck('artwork_id')->all();
|
||||
$candidateTagMap = DB::table('artwork_tag')
|
||||
->whereIn('artwork_id', $candidateIds)
|
||||
->select('artwork_id', 'tag_id')
|
||||
->get()
|
||||
->groupBy('artwork_id');
|
||||
|
||||
$candidateCatMap = DB::table('artwork_category')
|
||||
->whereIn('artwork_id', $candidateIds)
|
||||
->select('artwork_id', 'category_id')
|
||||
->get()
|
||||
->groupBy('artwork_id');
|
||||
|
||||
// Build content_type_id lookup for candidates (via categories table)
|
||||
$allCandidateCatIds = $candidateCatMap->flatten(1)->pluck('category_id')->unique()->all();
|
||||
$catContentTypeMap = $allCandidateCatIds !== []
|
||||
? DB::table('categories')
|
||||
->whereIn('id', $allCandidateCatIds)
|
||||
->whereNotNull('content_type_id')
|
||||
->pluck('content_type_id', 'id')
|
||||
->all()
|
||||
: [];
|
||||
$srcContentTypeSet = array_flip($srcContentTypeIds);
|
||||
|
||||
$srcTagSet = array_flip($srcTagIds);
|
||||
$srcCatSet = array_flip($srcCatIds);
|
||||
|
||||
// ── Score each candidate ───────────────────────────────────────────────
|
||||
$scored = [];
|
||||
foreach ($candidates as $cand) {
|
||||
$cTagIds = $candidateTagMap->get($cand->artwork_id, collect())->pluck('tag_id')->all();
|
||||
$cCatIds = $candidateCatMap->get($cand->artwork_id, collect())->pluck('category_id')->all();
|
||||
|
||||
// IDF-weighted tag overlap (spec §5.1)
|
||||
$tagScore = 0.0;
|
||||
foreach ($cTagIds as $tagId) {
|
||||
if (isset($srcTagSet[$tagId])) {
|
||||
$freq = $tagFreqs[$tagId] ?? 1;
|
||||
$tagScore += 1.0 / log(2 + $freq);
|
||||
}
|
||||
}
|
||||
|
||||
// Category match bonus
|
||||
$catScore = 0.0;
|
||||
foreach ($cCatIds as $catId) {
|
||||
if (isset($srcCatSet[$catId])) {
|
||||
$catScore = 1.0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Content type match bonus (spec §5.1)
|
||||
$ctScore = 0.0;
|
||||
foreach ($cCatIds as $catId) {
|
||||
$ctId = $catContentTypeMap[$catId] ?? null;
|
||||
if ($ctId !== null && isset($srcContentTypeSet[$ctId])) {
|
||||
$ctScore = 1.0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$scored[] = [
|
||||
'artwork_id' => (int) $cand->artwork_id,
|
||||
'user_id' => (int) $cand->user_id,
|
||||
'tag_score' => $tagScore,
|
||||
'cat_score' => $catScore,
|
||||
'score' => $tagScore + $catScore * 0.1 + $ctScore * 0.05,
|
||||
];
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
usort($scored, fn (array $a, array $b) => $b['score'] <=> $a['score']);
|
||||
|
||||
// ── Apply diversity (max per author) ───────────────────────────────────
|
||||
$authorCounts = [];
|
||||
$final = [];
|
||||
foreach ($scored as $item) {
|
||||
$authorId = $item['user_id'];
|
||||
$authorCounts[$authorId] = ($authorCounts[$authorId] ?? 0) + 1;
|
||||
if ($authorCounts[$authorId] > $maxPerAuthor) {
|
||||
continue;
|
||||
}
|
||||
$final[] = $item['artwork_id'];
|
||||
if (count($final) >= $resultLimit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Persist ────────────────────────────────────────────────────────────
|
||||
RecArtworkRec::query()->updateOrCreate(
|
||||
[
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_tags',
|
||||
'model_version' => $modelVersion,
|
||||
],
|
||||
[
|
||||
'recs' => $final,
|
||||
'computed_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
286
app/Jobs/RecComputeSimilarHybridJob.php
Normal file
286
app/Jobs/RecComputeSimilarHybridJob.php
Normal file
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\RecArtworkRec;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Compute hybrid similarity by blending tag, behavior, and optionally visual scores.
|
||||
*
|
||||
* Spec §7.4 — runs nightly.
|
||||
* Merges candidates from tag + behavior + vector lists, applies hybrid blend weights,
|
||||
* enforces diversity, stores top 30.
|
||||
*/
|
||||
final class RecComputeSimilarHybridJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 2;
|
||||
public int $timeout = 900;
|
||||
|
||||
public function __construct(
|
||||
private readonly ?int $artworkId = null,
|
||||
private readonly int $batchSize = 200,
|
||||
) {
|
||||
$queue = (string) config('recommendations.queue', 'default');
|
||||
if ($queue !== '') {
|
||||
$this->onQueue($queue);
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1');
|
||||
$vectorEnabled = (bool) config('recommendations.similarity.vector_enabled', false);
|
||||
$resultLimit = (int) config('recommendations.similarity.result_limit', 30);
|
||||
$maxPerAuthor = (int) config('recommendations.similarity.max_per_author', 2);
|
||||
$minCatsTop12 = (int) config('recommendations.similarity.min_categories_top12', 2);
|
||||
|
||||
$weights = $vectorEnabled
|
||||
? (array) config('recommendations.similarity.weights_with_vector')
|
||||
: (array) config('recommendations.similarity.weights_without_vector');
|
||||
|
||||
$query = Artwork::query()->public()->published()->select('id', 'user_id');
|
||||
|
||||
if ($this->artworkId !== null) {
|
||||
$query->where('id', $this->artworkId);
|
||||
}
|
||||
|
||||
$query->chunkById($this->batchSize, function ($artworks) use (
|
||||
$modelVersion, $vectorEnabled, $resultLimit, $maxPerAuthor, $minCatsTop12, $weights
|
||||
) {
|
||||
foreach ($artworks as $artwork) {
|
||||
try {
|
||||
$this->processArtwork(
|
||||
$artwork, $modelVersion, $vectorEnabled, $resultLimit,
|
||||
$maxPerAuthor, $minCatsTop12, $weights
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("[RecComputeSimilarHybrid] Failed for artwork {$artwork->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function processArtwork(
|
||||
Artwork $artwork,
|
||||
string $modelVersion,
|
||||
bool $vectorEnabled,
|
||||
int $resultLimit,
|
||||
int $maxPerAuthor,
|
||||
int $minCatsTop12,
|
||||
array $weights,
|
||||
): void {
|
||||
// ── Collect sub-lists ──────────────────────────────────────────────────
|
||||
$tagRec = RecArtworkRec::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('rec_type', 'similar_tags')
|
||||
->where('model_version', $modelVersion)
|
||||
->first();
|
||||
|
||||
$behRec = RecArtworkRec::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('rec_type', 'similar_behavior')
|
||||
->where('model_version', $modelVersion)
|
||||
->first();
|
||||
|
||||
$tagIds = $tagRec ? ($tagRec->recs ?? []) : [];
|
||||
$behIds = $behRec ? ($behRec->recs ?? []) : [];
|
||||
|
||||
$vecIds = [];
|
||||
$vecScores = [];
|
||||
if ($vectorEnabled) {
|
||||
$vecRec = RecArtworkRec::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('rec_type', 'similar_visual')
|
||||
->where('model_version', $modelVersion)
|
||||
->first();
|
||||
if ($vecRec) {
|
||||
$vecIds = $vecRec->recs ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
// Merge all candidate IDs
|
||||
$allIds = array_values(array_unique(array_merge($tagIds, $behIds, $vecIds)));
|
||||
|
||||
if ($allIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Build normalized score maps ────────────────────────────────────────
|
||||
$tagScoreMap = $this->rankToScore($tagIds);
|
||||
$behScoreMap = $this->rankToScore($behIds);
|
||||
$vecScoreMap = $this->rankToScore($vecIds);
|
||||
|
||||
// Fetch artwork metadata for category + author diversity
|
||||
$metaRows = DB::table('artworks')
|
||||
->whereIn('id', $allIds)
|
||||
->where('is_public', true)
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', now())
|
||||
->whereNull('deleted_at')
|
||||
->select('id', 'user_id')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$catMap = DB::table('artwork_category')
|
||||
->whereIn('artwork_id', $allIds)
|
||||
->select('artwork_id', 'category_id')
|
||||
->get()
|
||||
->groupBy('artwork_id');
|
||||
|
||||
// Source artwork categories
|
||||
$srcCatIds = DB::table('artwork_category')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->pluck('category_id')
|
||||
->all();
|
||||
$srcCatSet = array_flip($srcCatIds);
|
||||
|
||||
// ── Compute hybrid score ───────────────────────────────────────────────
|
||||
$scored = [];
|
||||
foreach ($allIds as $candidateId) {
|
||||
if (! $metaRows->has($candidateId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$meta = $metaRows->get($candidateId);
|
||||
$candidateCats = $catMap->get($candidateId, collect())->pluck('category_id')->all();
|
||||
|
||||
// Category overlap
|
||||
$catScore = 0.0;
|
||||
foreach ($candidateCats as $catId) {
|
||||
if (isset($srcCatSet[$catId])) {
|
||||
$catScore = 1.0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$tagS = $tagScoreMap[$candidateId] ?? 0.0;
|
||||
$behS = $behScoreMap[$candidateId] ?? 0.0;
|
||||
$vecS = $vecScoreMap[$candidateId] ?? 0.0;
|
||||
|
||||
if ($vectorEnabled) {
|
||||
$score = ($weights['visual'] ?? 0.45) * $vecS
|
||||
+ ($weights['tag'] ?? 0.25) * $tagS
|
||||
+ ($weights['behavior'] ?? 0.20) * $behS
|
||||
+ ($weights['category'] ?? 0.10) * $catScore;
|
||||
} else {
|
||||
$score = ($weights['tag'] ?? 0.55) * $tagS
|
||||
+ ($weights['behavior'] ?? 0.35) * $behS
|
||||
+ ($weights['category'] ?? 0.10) * $catScore;
|
||||
}
|
||||
|
||||
$scored[] = [
|
||||
'artwork_id' => $candidateId,
|
||||
'user_id' => (int) $meta->user_id,
|
||||
'cat_ids' => $candidateCats,
|
||||
'score' => $score,
|
||||
];
|
||||
}
|
||||
|
||||
usort($scored, fn (array $a, array $b) => $b['score'] <=> $a['score']);
|
||||
|
||||
// ── Diversity enforcement ──────────────────────────────────────────────
|
||||
$authorCounts = [];
|
||||
$final = [];
|
||||
$catsInTop12 = [];
|
||||
|
||||
foreach ($scored as $item) {
|
||||
$authorId = $item['user_id'];
|
||||
$authorCounts[$authorId] = ($authorCounts[$authorId] ?? 0) + 1;
|
||||
|
||||
if ($authorCounts[$authorId] > $maxPerAuthor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$final[] = $item;
|
||||
|
||||
if (count($final) <= 12) {
|
||||
foreach ($item['cat_ids'] as $cId) {
|
||||
$catsInTop12[$cId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (count($final) >= $resultLimit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Min-categories enforcement in top 12 (spec §6) ────────────────────
|
||||
if (count($catsInTop12) < $minCatsTop12 && count($final) >= 12) {
|
||||
// Find items beyond the initial selection that introduce a new category
|
||||
$usedIds = array_flip(array_column($final, 'artwork_id'));
|
||||
$promotable = [];
|
||||
foreach ($scored as $item) {
|
||||
if (isset($usedIds[$item['artwork_id']])) {
|
||||
continue;
|
||||
}
|
||||
$newCats = array_diff($item['cat_ids'], array_keys($catsInTop12));
|
||||
if ($newCats !== []) {
|
||||
$promotable[] = $item;
|
||||
if (count($promotable) >= ($minCatsTop12 - count($catsInTop12))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Inject promoted items at position 12 (end of visible top block)
|
||||
if ($promotable !== []) {
|
||||
$top = array_slice($final, 0, 11);
|
||||
$rest = array_slice($final, 11);
|
||||
$final = array_merge($top, $promotable, $rest);
|
||||
$final = array_slice($final, 0, $resultLimit);
|
||||
}
|
||||
}
|
||||
|
||||
$finalIds = array_column($final, 'artwork_id');
|
||||
|
||||
if ($finalIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
RecArtworkRec::query()->updateOrCreate(
|
||||
[
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_hybrid',
|
||||
'model_version' => $modelVersion,
|
||||
],
|
||||
[
|
||||
'recs' => $finalIds,
|
||||
'computed_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a ranked list of IDs into a score map (1.0 at rank 0, decaying).
|
||||
*
|
||||
* @param list<int> $ids
|
||||
* @return array<int, float>
|
||||
*/
|
||||
private function rankToScore(array $ids): array
|
||||
{
|
||||
$map = [];
|
||||
$total = count($ids);
|
||||
if ($total === 0) {
|
||||
return $map;
|
||||
}
|
||||
|
||||
foreach ($ids as $rank => $id) {
|
||||
// Linear decay from 1.0 → ~0.0
|
||||
$map[(int) $id] = 1.0 - ($rank / max(1, $total));
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
27
app/Listeners/Posts/SendArtworkSharedNotification.php
Normal file
27
app/Listeners/Posts/SendArtworkSharedNotification.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Posts;
|
||||
|
||||
use App\Events\Posts\ArtworkShared;
|
||||
use App\Notifications\ArtworkSharedNotification;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
||||
class SendArtworkSharedNotification implements ShouldQueue
|
||||
{
|
||||
public function handle(ArtworkShared $event): void
|
||||
{
|
||||
// Notify the artwork's original owner
|
||||
$originalOwner = $event->artwork->user;
|
||||
|
||||
// Don't notify if sharer is the owner
|
||||
if ($originalOwner->id === $event->sharer->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$originalOwner->notify(new ArtworkSharedNotification(
|
||||
post: $event->post,
|
||||
artwork: $event->artwork,
|
||||
sharer: $event->sharer,
|
||||
));
|
||||
}
|
||||
}
|
||||
27
app/Listeners/Posts/SendPostCommentedNotification.php
Normal file
27
app/Listeners/Posts/SendPostCommentedNotification.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Posts;
|
||||
|
||||
use App\Events\Posts\PostCommented;
|
||||
use App\Notifications\PostCommentedNotification;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
||||
class SendPostCommentedNotification implements ShouldQueue
|
||||
{
|
||||
public function handle(PostCommented $event): void
|
||||
{
|
||||
// Notify the post owner
|
||||
$postOwner = $event->post->user;
|
||||
|
||||
// Don't notify for self-comments
|
||||
if ($postOwner->id === $event->commenter->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$postOwner->notify(new PostCommentedNotification(
|
||||
post: $event->post,
|
||||
comment: $event->comment,
|
||||
commenter: $event->commenter,
|
||||
));
|
||||
}
|
||||
}
|
||||
43
app/Mail/StaffApplicationReceived.php
Normal file
43
app/Mail/StaffApplicationReceived.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use App\Models\StaffApplication;
|
||||
|
||||
class StaffApplicationReceived extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public StaffApplication $application;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(StaffApplication $application)
|
||||
{
|
||||
$this->application = $application;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message.
|
||||
*/
|
||||
public function build()
|
||||
{
|
||||
$topicLabel = match ($this->application->topic ?? 'apply') {
|
||||
'apply' => 'Application',
|
||||
'bug' => 'Bug Report',
|
||||
'contact' => 'Contact',
|
||||
default => 'Message',
|
||||
};
|
||||
|
||||
return $this->subject("New {$topicLabel}: " . ($this->application->name ?? 'Unnamed'))
|
||||
->from(config('mail.from.address'), config('mail.from.name'))
|
||||
->view('emails.staff_application_received')
|
||||
->text('emails.staff_application_received_plain')
|
||||
->with(['application' => $this->application, 'topicLabel' => $topicLabel]);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user