Compare commits

..

10 Commits

Author SHA1 Message Date
f6772f673b login update 2026-03-05 11:24:37 +01:00
5a33ca55a1 fixes 2026-03-03 21:00:18 +01:00
b9c2d8597d feat: Inertia profile settings page, Studio edit redesign, EGS, Nova UI components\n\n- Redesign /dashboard/profile as Inertia React page (Settings/ProfileEdit)\n with SettingsLayout sidebar, Nova UI components (TextInput, Textarea,\n Toggle, Select, RadioGroup, Modal, Button), avatar drag-and-drop,\n password change, and account deletion sections\n- Redesign Studio artwork edit page with two-column layout, Nova components,\n integrated TagPicker, and version history modal\n- Add shared MarkdownEditor component\n- Add Early-Stage Growth System (EGS): SpotlightEngine, FeedBlender,\n GridFiller, AdaptiveTimeWindow, ActivityLayer, admin panel\n- Fix upload category/tag persistence (V1+V2 paths)\n- Fix tag source enum, category tree display, binding resolution\n- Add settings.jsx Vite entry, settings.blade.php wrapper\n- Update ProfileController with JSON response support for API calls\n- Various route fixes (profile.edit, toolbar settings link)" 2026-03-03 20:57:43 +01:00
dc51d65440 feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile
Forum:
- TipTap WYSIWYG editor with full toolbar
- @emoji-mart/react emoji picker (consistent with tweets)
- @mention autocomplete with user search API
- Fix PHP 8.4 parse errors in Blade templates
- Fix thread data display (paginator items)
- Align forum page widths to max-w-5xl

Discover:
- Extract shared _nav.blade.php partial
- Add missing nav links to for-you page
- Add Following link for authenticated users

Feed/Posts:
- Post model, controllers, policies, migrations
- Feed page components (PostComposer, FeedCard, etc)
- Post reactions, comments, saves, reports, sharing
- Scheduled publishing support
- Link preview controller

Profile:
- Profile page components (ProfileHero, ProfileTabs)
- Profile API controller

Uploads:
- Upload wizard enhancements
- Scheduled publish picker
- Studio status bar and readiness checklist
2026-03-03 09:48:31 +01:00
1266f81d35 feat: upload wizard refactor + vision AI tags + artwork versioning
Upload wizard:
- Refactored UploadWizard into modular steps (Step1FileUpload, Step2Details, Step3Publish)
- Extracted reusable hooks: useUploadMachine, useFileValidation, useVisionTags
- Extracted reusable components: CategorySelector, ContentTypeSelector
- Added TagPicker component (studio-style list picker with AI badge + new-tag insertion)
- Fixed TagInput auto-open bug (hasFocusedRef guard)
- Replaced TagInput with TagPicker in UploadSidebar

Vision AI tag suggestions:
- Add UploadVisionSuggestController: sync POST /api/uploads/{id}/vision-suggest
- Calls vision.klevze.net/analyze/all on upload completion (before step 2 opens)
- Two-phase useVisionTags: immediate gateway call + background DB polling
- Trigger fires on uploadReady (not step change) so tags arrive before user sees step 2
- Added vision.gateway config block with VISION_GATEWAY_URL env

Artwork versioning system:
- ArtworkVersion / ArtworkVersionEvent models
- ArtworkVersioningService: createNewVersion, restoreVersion, rate limiting, ranking decay
- Migrations: artwork_versions, artwork_version_events, versioning columns on artworks
- Studio API routes: GET versions, POST restore/{version_id}
- Feature tests: ArtworkVersioningTest (13 cases)
2026-03-01 14:56:46 +01:00
a875203482 feat: Nova UI component library + Studio dropdown/picker polish
- Add Nova UI library: Button, TextInput, Textarea, FormField, Select,
  NovaSelect, Checkbox, Radio/RadioGroup, Toggle, DatePicker,
  DateRangePicker, Modal + barrel index.js
- Replace all native <select> in Studio with NovaSelect (StudioFilters,
  StudioToolbar, BulkActionsBar) including frosted-glass portal and
  category group headers
- Replace native checkboxes in StudioGridCard, StudioTable, UploadSidebar,
  UploadWizard, Upload/Index with custom Checkbox component
- Add nova-scrollbar CSS utility (thin 4px, semi-transparent)
- Fix portal position drift: use viewport-relative coords (no scrollY offset)
  for NovaSelect, DatePicker and DateRangePicker
- Close portals on external scroll instead of remeasuring
- Improve hover highlight visibility in NovaSelect (bg-white/[0.13])
- Move search icon to right side in NovaSelect dropdown
- Reduce Studio layout top spacing (py-6 -> pt-4 pb-8)
- Add StudioCheckbox and SquareCheckbox backward-compat shims
- Add sync.sh rsync deploy script
2026-03-01 10:41:43 +01:00
e3ca845a6d Studio: make grid checkbox rectangular and commit table changes 2026-03-01 08:43:48 +01:00
211dc58884 Studio: use site CTA for Upload button (bg-sky-600) instead of accent 2026-03-01 08:30:06 +01:00
916bb29a53 feat(trending): switch trending endpoints to Ranking V2 ranking_score\n\n- discoverTrending() now sorts by ranking_score:desc + engagement_velocity:desc\n- HomepageService::getTrending() now sorts by ranking_score:desc + velocity\n- DB fallback joins artwork_stats for ranking_score sort\n- Both trending endpoints filter to last 30 days (spec §6)\n- Add created_at to Meilisearch filterableAttributes for date filtering\n- Synced index settings" 2026-02-28 16:47:08 +01:00
de3ec22ee5 feat: Ranking Engine V2 — intelligent scoring with shares, authority, decay & velocity\n\n- Add ArtworkRankingService with V2 formula:\n ranking_score = (base × authority × decay) + velocity_boost\n Base: views×0.2 + downloads×1.5 + favourites×2.5 + comments×3.0 + shares×4.0\n Authority: 1 + (log10(1+followers) + fav_received/1000) × 0.05\n Decay: 1 / (1 + hours/48)\n Velocity: 24h signals × velocity_weights × 0.5\n\n- Add nova:recalculate-rankings command (--chunk, --sync-rank-scores, --skip-index)\n- Add migration: ranking_score, engagement_velocity, shares/comments counts to artwork_stats\n- Upgrade RankingService.computeScores() with shares + comments + velocity\n- Update Meilisearch sortableAttributes: ranking_score, shares_count, engagement_velocity, comments_count\n- Update toSearchableArray() to expose V2 fields\n- Schedule every 30 min with overlap protection\n- Verified: 49733 artworks scored successfully" 2026-02-28 16:41:15 +01:00
506 changed files with 51520 additions and 2463 deletions

View File

@@ -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

View File

@@ -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).

View 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;
}
}

View File

@@ -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;
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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 1015 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;
}
}

View 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);
}
});
}
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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');
}
/**

View 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,
) {}
}

View 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,
) {}
}

View 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),
]);
}
}

View File

@@ -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([

View 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);
}
}

View 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.']);
}
}

View 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)]);
}
}

View 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,
],
];
}
}

View File

@@ -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]);
}
}

View 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.']);
}
}

View 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'],
]);
}
}

View 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]);
}
}

View 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]);
}
}

View 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);
}
}

View 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']]);
}
}

View 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(),
],
]);
}
}
}

View 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);
}
}

View File

@@ -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]);
}
}

View 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();
}
}

View File

@@ -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,

View File

@@ -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 3060 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ørensenDice-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))
);
}

View 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,
];
}
}

View File

@@ -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(

View 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);
}
}
}

View File

@@ -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,
);
}
}

View 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,
};
}
}

View File

@@ -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,
]);
}

View File

@@ -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,

View File

@@ -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,
];
});

View File

@@ -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(),

View File

@@ -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) [

View File

@@ -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,
];

View File

@@ -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',
],

View 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,
);
}
}

View 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,
);
}
}

View 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,
);
}
}

View 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,
);
}
}

View 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,
);
}
}

View 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,
);
}
}

View 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()]);
}
}
}

View 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();
}
}

View File

@@ -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,
]);
}
}

View File

@@ -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) [

View File

@@ -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';
}

View File

@@ -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,
];

View 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.');
}
}

View File

@@ -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();
}
}
}

View 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],
]),
]);
}
}

View 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);
}
}

View File

@@ -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,
];
});

View 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();
}
}
}

View 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');
}
}

View 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',
]);
}
}

View 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],
]),
]);
}
}

View 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,
],
]);
}
}

View 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),
]);
}
}

View 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,
],
],
]);
}
}

View 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,
]);
}
}

View 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,
]);
}
}

View 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',
]);
}
}

View 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]);
}
}

View 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',
]);
}
}

View 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],
]),
]);
}
}

View 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'],
]),
]);
}
}

View 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],
]),
]);
}
}

View 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],
]),
]);
}
}

View File

@@ -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',
]);
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -14,5 +14,6 @@ class VerifyCsrfToken extends Middleware
protected $except = [
'chat_post',
'chat_post/*',
// Apple Sign In removed — no special CSRF exception required
];
}

View File

@@ -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',
];

View 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.',
];
}
}

View 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.',
];
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View File

@@ -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'],

View File

@@ -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',
];
}

View File

@@ -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();

View 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}");
}
}

View 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() 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'],
);
}
}
}

View 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(),
],
);
}
}

View 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(),
],
);
}
}

View 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;
}
}

View 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,
));
}
}

View 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,
));
}
}

View 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