feat: increase gallery grid from 4 to 5 columns per row on desktopfeat: increase gallery grid from 4 to 5 columns per row on desktop
This commit is contained in:
436
app/Console/Commands/AiTagArtworksCommand.php
Normal file
436
app/Console/Commands/AiTagArtworksCommand.php
Normal file
@@ -0,0 +1,436 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\TagService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Generate AI tags for artworks using a local LM Studio vision model.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan artworks:ai-tag
|
||||
* php artisan artworks:ai-tag --after-id=1000 --chunk=20 --dry-run
|
||||
* php artisan artworks:ai-tag --limit=100 --skip-tagged
|
||||
* php artisan artworks:ai-tag --artwork-id=242 # process a single artwork by ID
|
||||
* php artisan artworks:ai-tag --artwork-id=242 --dump-curl # print equivalent curl command (no API call made)
|
||||
* php artisan artworks:ai-tag --artwork-id=242 --debug # print CDN URL, file size, magic bytes and data-URI prefix
|
||||
* php artisan artworks:ai-tag --url=http://192.168.1.5:8200 --model=google/gemma-3-4b
|
||||
*/
|
||||
final class AiTagArtworksCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:ai-tag
|
||||
{--artwork-id= : Process only this single artwork ID (bypasses public/approved scope)}
|
||||
{--after-id=0 : Skip artworks with ID ≤ this value (useful for resuming)}
|
||||
{--limit= : Stop after processing this many artworks}
|
||||
{--chunk=50 : DB chunk size}
|
||||
{--dry-run : Print tags but do not persist them}
|
||||
{--skip-tagged : Skip artworks that already have at least one AI tag}
|
||||
{--url-only : Send CDN URL instead of base64 (only works if LM Studio can reach the CDN)}
|
||||
{--dump-curl : Print the equivalent curl command for the API call and skip the actual request}
|
||||
{--debug : Print CDN URL, file size, magic bytes and data-URI prefix for each image}
|
||||
{--url= : LM Studio base URL (overrides config/env)}
|
||||
{--model= : Model identifier (overrides config/env)}
|
||||
{--clear-ai-tags : Delete existing AI tags for each artwork before re-tagging}
|
||||
';
|
||||
|
||||
protected $description = 'Generate tags for artworks via a local LM Studio vision model';
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Prompt
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private const SYSTEM_PROMPT = <<<'PROMPT'
|
||||
You are an expert at analysing visual artwork and generating concise, descriptive tags.
|
||||
PROMPT;
|
||||
|
||||
private const USER_PROMPT = <<<'PROMPT'
|
||||
Analyse the artwork image and return a JSON array of relevant tags.
|
||||
Cover: art style, subject/theme, dominant colours, mood, technique, and medium where visible.
|
||||
|
||||
Rules:
|
||||
- Return ONLY a valid JSON array of lowercase strings — no markdown, no explanation.
|
||||
- Each tag must be 1–4 words, no punctuation except hyphens.
|
||||
- Between 6 and 12 tags total.
|
||||
|
||||
Example output:
|
||||
["digital painting","fantasy","portrait","dark tones","glowing eyes","detailed","dramatic lighting"]
|
||||
PROMPT;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function __construct(private readonly TagService $tagService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$artworkId = $this->option('artwork-id') !== null ? (int) $this->option('artwork-id') : null;
|
||||
$afterId = max(0, (int) $this->option('after-id'));
|
||||
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
||||
$chunk = max(1, min((int) $this->option('chunk'), 200));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$skipTagged = (bool) $this->option('skip-tagged');
|
||||
$dumpCurl = (bool) $this->option('dump-curl');
|
||||
$verbose = (bool) $this->option('debug');
|
||||
$useBase64 = !(bool) $this->option('url-only');
|
||||
$clearAiTags = (bool) $this->option('clear-ai-tags');
|
||||
|
||||
$baseUrl = rtrim((string) ($this->option('url') ?: config('vision.lm_studio.base_url')), '/');
|
||||
$model = (string) ($this->option('model') ?: config('vision.lm_studio.model'));
|
||||
$maxTags = (int) config('vision.lm_studio.max_tags', 12);
|
||||
|
||||
$this->info("LM Studio : {$baseUrl}");
|
||||
$this->info("Model : {$model}");
|
||||
$this->info("Image mode : " . ($useBase64 ? 'base64 (default)' : 'CDN URL (--url-only)'));
|
||||
$this->info("Dry run : " . ($dryRun ? 'YES' : 'no'));
|
||||
$this->info("Clear AI : " . ($clearAiTags ? 'YES — existing AI tags deleted first' : 'no'));
|
||||
if ($artworkId !== null) {
|
||||
$this->info("Artwork ID : {$artworkId} (single-artwork mode)");
|
||||
}
|
||||
$this->line('');
|
||||
|
||||
// Single-artwork mode: bypass public/approved scope so any artwork can be tested.
|
||||
if ($artworkId !== null) {
|
||||
$artwork = Artwork::withTrashed()->find($artworkId);
|
||||
if ($artwork === null) {
|
||||
$this->error("Artwork #{$artworkId} not found.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
$limit = 1;
|
||||
$query = Artwork::withTrashed()->where('id', $artworkId);
|
||||
} else {
|
||||
$query = Artwork::query()
|
||||
->public()
|
||||
->where('id', '>', $afterId)
|
||||
->whereNotNull('hash')
|
||||
->whereNotNull('thumb_ext')
|
||||
->orderBy('id');
|
||||
|
||||
if ($skipTagged) {
|
||||
// Exclude artworks that already have an AI-sourced tag in the pivot.
|
||||
$query->whereDoesntHave('tags', fn ($q) => $q->where('artwork_tag.source', 'ai'));
|
||||
}
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$tagged = 0;
|
||||
$skipped = 0;
|
||||
$errors = 0;
|
||||
|
||||
$query->chunkById($chunk, function ($artworks) use (
|
||||
&$processed, &$tagged, &$skipped, &$errors,
|
||||
$limit, $dryRun, $dumpCurl, $verbose, $useBase64, $baseUrl, $model, $maxTags, $clearAiTags,
|
||||
) {
|
||||
foreach ($artworks as $artwork) {
|
||||
if ($limit !== null && $processed >= $limit) {
|
||||
return false; // stop iteration
|
||||
}
|
||||
|
||||
$processed++;
|
||||
|
||||
$imageUrl = $artwork->thumbUrl('md');
|
||||
if ($imageUrl === null) {
|
||||
$this->warn(" [#{$artwork->id}] No thumb URL — skip");
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(" [#{$artwork->id}] {$artwork->title}");
|
||||
|
||||
// Remove AI tags first if requested.
|
||||
if ($clearAiTags) {
|
||||
$aiTagIds = DB::table('artwork_tag')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('source', 'ai')
|
||||
->pluck('tag_id')
|
||||
->all();
|
||||
|
||||
if ($aiTagIds !== []) {
|
||||
if (!$dryRun) {
|
||||
$this->tagService->detachTags($artwork, $aiTagIds);
|
||||
}
|
||||
$this->line(' ✂ Cleared ' . count($aiTagIds) . ' existing AI tag(s)' . ($dryRun ? ' (dry-run)' : ''));
|
||||
}
|
||||
}
|
||||
|
||||
if ($verbose) {
|
||||
$this->line(" CDN URL : {$imageUrl}");
|
||||
}
|
||||
|
||||
try {
|
||||
$tags = $this->fetchTags($baseUrl, $model, $imageUrl, $useBase64, $maxTags, $dumpCurl, $verbose);
|
||||
} catch (Throwable $e) {
|
||||
$this->error(" ✗ API error: " . $e->getMessage());
|
||||
// Show first 120 chars of the response body for easier debugging.
|
||||
if (str_contains($e->getMessage(), 'status code')) {
|
||||
$this->line(" (use --dry-run to test without saving)");
|
||||
}
|
||||
Log::error('artworks:ai-tag API error', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($tags === []) {
|
||||
$this->warn(" ✗ No tags returned");
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$tagList = implode(', ', $tags);
|
||||
$this->line(" → {$tagList}");
|
||||
|
||||
if (!$dryRun) {
|
||||
$aiTagPayload = array_map(fn (string $t) => ['tag' => $t, 'confidence' => null], $tags);
|
||||
|
||||
try {
|
||||
$this->tagService->attachAiTags($artwork, $aiTagPayload);
|
||||
$tagged++;
|
||||
} catch (Throwable $e) {
|
||||
$this->error(" ✗ Save error: " . $e->getMessage());
|
||||
Log::error('artworks:ai-tag save error', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$errors++;
|
||||
}
|
||||
} else {
|
||||
$tagged++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->line('');
|
||||
$this->info("Done. processed={$processed} tagged={$tagged} skipped={$skipped} errors={$errors}");
|
||||
|
||||
return $errors > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// LM Studio API call
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function fetchTags(
|
||||
string $baseUrl,
|
||||
string $model,
|
||||
string $imageUrl,
|
||||
bool $useBase64,
|
||||
int $maxTags,
|
||||
bool $dumpCurl = false,
|
||||
bool $verbose = false,
|
||||
): array {
|
||||
$imageContent = $useBase64
|
||||
? $this->buildBase64ImageContent($imageUrl, $verbose)
|
||||
: ['type' => 'image_url', 'image_url' => ['url' => $imageUrl]];
|
||||
|
||||
$payload = [
|
||||
'model' => $model,
|
||||
'temperature' => (float) config('vision.lm_studio.temperature', 0.3),
|
||||
'max_tokens' => (int) config('vision.lm_studio.max_tokens', 300),
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => self::SYSTEM_PROMPT,
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => [
|
||||
$imageContent,
|
||||
['type' => 'text', 'text' => self::USER_PROMPT],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$timeout = (int) config('vision.lm_studio.timeout', 60);
|
||||
$connectTimeout = (int) config('vision.lm_studio.connect_timeout', 5);
|
||||
$endpoint = "{$baseUrl}/v1/chat/completions";
|
||||
|
||||
// --dump-curl: write payload to a temp file and print the equivalent curl command.
|
||||
if ($dumpCurl) {
|
||||
$jsonPayload = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
// Truncate any base64 data URIs in the printed output so the terminal stays readable.
|
||||
$printable = preg_replace(
|
||||
'/("data:[^;]+;base64,)([A-Za-z0-9+\/=]{60})[A-Za-z0-9+\/=]+(")/',
|
||||
'$1$2...[base64 truncated]$3',
|
||||
$jsonPayload,
|
||||
) ?? $jsonPayload;
|
||||
|
||||
// Write the full (untruncated) payload to a temp file for use with curl --data.
|
||||
$tmpJson = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sbtag_payload_' . uniqid() . '.json';
|
||||
file_put_contents($tmpJson, $jsonPayload);
|
||||
|
||||
$this->line('');
|
||||
$this->line('<fg=yellow>--- Payload (base64 truncated for display) ---</>');
|
||||
$this->line($printable);
|
||||
$this->line('');
|
||||
$this->line('<fg=yellow>--- curl command (full payload in temp file) ---</>');
|
||||
$this->line(
|
||||
'curl -s -X POST ' . escapeshellarg($endpoint)
|
||||
. ' -H ' . escapeshellarg('Content-Type: application/json')
|
||||
. ' --data @' . escapeshellarg($tmpJson)
|
||||
. ' | python -m json.tool'
|
||||
);
|
||||
$this->line('');
|
||||
$this->info("Full JSON payload written to: {$tmpJson}");
|
||||
|
||||
// Return empty — no real API call is made.
|
||||
return [];
|
||||
}
|
||||
|
||||
$response = Http::timeout($timeout)
|
||||
->connectTimeout($connectTimeout)
|
||||
->post($endpoint, $payload)
|
||||
->throw();
|
||||
|
||||
$body = $response->json();
|
||||
$content = $body['choices'][0]['message']['content'] ?? '';
|
||||
|
||||
return $this->parseTags((string) $content, $maxTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the image using the system curl binary (raw bytes, no encoding surprises),
|
||||
* base64-encode from the local file, then delete it.
|
||||
*
|
||||
* Using curl directly is more reliable than the Laravel Http client here because it
|
||||
* avoids gzip/deflate decoding issues, chunked-transfer quirks, and header parsing
|
||||
* edge cases that could corrupt the image bytes before encoding.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
* @throws \RuntimeException if curl fails or the file is empty
|
||||
*/
|
||||
private function buildBase64ImageContent(string $imageUrl, bool $verbose = false): array
|
||||
{
|
||||
$ext = strtolower(pathinfo(parse_url($imageUrl, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
|
||||
$mime = match ($ext) {
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
default => 'image/jpeg',
|
||||
};
|
||||
|
||||
$tmpPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sbtag_' . uniqid() . '.' . ($ext ?: 'jpg');
|
||||
|
||||
try {
|
||||
exec(
|
||||
'curl -s -f -L --max-time 30 -o ' . escapeshellarg($tmpPath) . ' ' . escapeshellarg($imageUrl),
|
||||
$output,
|
||||
$exitCode,
|
||||
);
|
||||
|
||||
if ($exitCode !== 0 || !file_exists($tmpPath) || filesize($tmpPath) === 0) {
|
||||
throw new \RuntimeException("curl failed to download image (exit={$exitCode}, size=" . (file_exists($tmpPath) ? filesize($tmpPath) : 'N/A') . "): {$imageUrl}");
|
||||
}
|
||||
|
||||
$rawBytes = file_get_contents($tmpPath);
|
||||
if ($rawBytes === false || $rawBytes === '') {
|
||||
throw new \RuntimeException("file_get_contents returned empty after curl download: {$tmpPath}");
|
||||
}
|
||||
|
||||
// LM Studio does not support WebP. Convert to JPEG via GD if needed.
|
||||
if ($mime === 'image/webp') {
|
||||
$convertedPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sbtag_conv_' . uniqid() . '.jpg';
|
||||
try {
|
||||
if (!function_exists('imagecreatefromwebp')) {
|
||||
throw new \RuntimeException('GD extension with WebP support is required to convert WebP images. Enable ext-gd with WebP support in php.ini.');
|
||||
}
|
||||
$img = imagecreatefromwebp($tmpPath);
|
||||
if ($img === false) {
|
||||
throw new \RuntimeException("GD failed to load WebP: {$tmpPath}");
|
||||
}
|
||||
imagejpeg($img, $convertedPath, 92);
|
||||
imagedestroy($img);
|
||||
$rawBytes = file_get_contents($convertedPath);
|
||||
$mime = 'image/jpeg';
|
||||
if ($verbose) {
|
||||
$this->line(' Convert : WebP → JPEG (LM Studio does not accept WebP)');
|
||||
}
|
||||
} finally {
|
||||
@unlink($convertedPath);
|
||||
}
|
||||
}
|
||||
|
||||
if ($verbose) {
|
||||
$fileSize = filesize($tmpPath);
|
||||
// Show first 8 bytes as hex to confirm it's a real image, not an HTML error page.
|
||||
$magicHex = strtoupper(bin2hex(substr($rawBytes, 0, 8)));
|
||||
$this->line(" File : {$tmpPath}");
|
||||
$this->line(" Size : {$fileSize} bytes");
|
||||
$this->line(" Magic : {$magicHex} (JPEG=FFD8FF, PNG=89504E47, WEBP=52494646)");
|
||||
}
|
||||
|
||||
$base64 = base64_encode($rawBytes);
|
||||
$dataUri = "data:{$mime};base64,{$base64}";
|
||||
|
||||
if ($verbose) {
|
||||
$this->line(" MIME : {$mime}");
|
||||
$this->line(" URI pfx : " . substr($dataUri, 0, 60) . '...');
|
||||
}
|
||||
} finally {
|
||||
@unlink($tmpPath);
|
||||
}
|
||||
|
||||
return ['type' => 'image_url', 'image_url' => ['url' => $dataUri]];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Response parsing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract a JSON array from the model's response text.
|
||||
*
|
||||
* The model should return just the array, but may include surrounding text
|
||||
* or markdown code fences, so we search for the first `[…]` block.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function parseTags(string $content, int $maxTags): array
|
||||
{
|
||||
$content = trim($content);
|
||||
|
||||
// Strip markdown code fences if present (```json … ```)
|
||||
$content = preg_replace('/^```(?:json)?\s*/i', '', $content) ?? $content;
|
||||
$content = preg_replace('/\s*```$/', '', $content) ?? $content;
|
||||
|
||||
// Extract the first JSON array from the text.
|
||||
if (!preg_match('/(\[.*?\])/s', $content, $matches)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($matches[1], true);
|
||||
if (!is_array($decoded)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tags = [];
|
||||
foreach ($decoded as $item) {
|
||||
if (!is_string($item)) {
|
||||
continue;
|
||||
}
|
||||
$clean = trim(strtolower((string) $item));
|
||||
if ($clean !== '') {
|
||||
$tags[] = $clean;
|
||||
}
|
||||
}
|
||||
|
||||
// Respect the configured max-tags ceiling.
|
||||
return array_slice(array_unique($tags), 0, $maxTags);
|
||||
}
|
||||
}
|
||||
92
app/Console/Commands/FixTagNamesCommand.php
Normal file
92
app/Console/Commands/FixTagNamesCommand.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\TagNormalizer;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* One-time (and idempotent) command to convert slug-style tag names to
|
||||
* human-readable display names.
|
||||
*
|
||||
* A tag is considered "slug-style" when its name is identical to its slug
|
||||
* (e.g. name="digital-art", slug="digital-art"). Tags that already have a
|
||||
* custom name (user-edited) are left untouched.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan tags:fix-names
|
||||
* php artisan tags:fix-names --dry-run
|
||||
*/
|
||||
final class FixTagNamesCommand extends Command
|
||||
{
|
||||
protected $signature = 'tags:fix-names
|
||||
{--dry-run : Show what would change without writing to the database}
|
||||
';
|
||||
|
||||
protected $description = 'Convert slug-style tag names (e.g. "digital-art") to readable names ("Digital Art")';
|
||||
|
||||
public function __construct(private readonly TagNormalizer $normalizer)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY-RUN — no changes will be written.');
|
||||
}
|
||||
|
||||
// Only fix rows where name === slug (those were created by the old code).
|
||||
$rows = DB::table('tags')
|
||||
->whereColumn('name', 'slug')
|
||||
->orderBy('id')
|
||||
->get(['id', 'name', 'slug']);
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
$this->info('Nothing to fix — all tag names are already human-readable.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$rows->count()} tag(s) with slug-style names.");
|
||||
|
||||
$updated = 0;
|
||||
$bar = $this->output->createProgressBar($rows->count());
|
||||
$bar->start();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$displayName = $this->normalizer->toDisplayName($row->slug);
|
||||
|
||||
if ($displayName === $row->name) {
|
||||
$bar->advance();
|
||||
continue; // Already correct (e.g. single-word tag "cars" → "Cars" — wait, that would differ)
|
||||
}
|
||||
|
||||
if ($this->output->isVerbose()) {
|
||||
$this->newLine();
|
||||
$this->line(" {$row->slug} → \"{$displayName}\"");
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
DB::table('tags')
|
||||
->where('id', $row->id)
|
||||
->update(['name' => $displayName]);
|
||||
}
|
||||
|
||||
$updated++;
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
$suffix = $dryRun ? ' (dry-run, nothing written)' : '';
|
||||
$this->info("Updated {$updated} of {$rows->count()} tag(s){$suffix}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
288
app/Console/Commands/ImportLegacyAwards.php
Normal file
288
app/Console/Commands/ImportLegacyAwards.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ArtworkAward;
|
||||
use App\Models\ArtworkAwardStat;
|
||||
use App\Services\ArtworkAwardService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Migrates legacy `users_opinions` (projekti_old_skinbase) into `artwork_awards`.
|
||||
*
|
||||
* Score mapping (legacy score → new medal):
|
||||
* 4 → gold (weight 3)
|
||||
* 3 → silver (weight 2)
|
||||
* 2 → bronze (weight 1)
|
||||
* 1 → skipped (too low to map meaningfully)
|
||||
*
|
||||
* Usage:
|
||||
* php artisan awards:import-legacy
|
||||
* php artisan awards:import-legacy --dry-run
|
||||
* php artisan awards:import-legacy --chunk=500
|
||||
* php artisan awards:import-legacy --skip-stats (skip final stats recalc)
|
||||
*/
|
||||
class ImportLegacyAwards extends Command
|
||||
{
|
||||
protected $signature = 'awards:import-legacy
|
||||
{--dry-run : Preview only — no writes to DB}
|
||||
{--chunk=250 : Rows to process per batch}
|
||||
{--skip-stats : Skip per-artwork stats recalculation at the end}
|
||||
{--force : Overwrite existing awards instead of skipping duplicates}';
|
||||
|
||||
protected $description = 'Import legacy users_opinions into artwork_awards';
|
||||
|
||||
/** Maps legacy score value → medal string */
|
||||
private const SCORE_MAP = [
|
||||
4 => 'gold',
|
||||
3 => 'silver',
|
||||
2 => 'bronze',
|
||||
];
|
||||
|
||||
public function handle(ArtworkAwardService $service): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$skipStats = (bool) $this->option('skip-stats');
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY-RUN] No data will be written.');
|
||||
}
|
||||
|
||||
// Verify legacy connection is reachable
|
||||
try {
|
||||
DB::connection('legacy')->getPdo();
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Cannot connect to legacy database: ' . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! DB::connection('legacy')->getSchemaBuilder()->hasTable('users_opinions')) {
|
||||
$this->error('Legacy table `users_opinions` not found.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Pre-load sets of valid artwork IDs and user IDs from the new DB
|
||||
$this->info('Loading new-DB artwork and user ID sets…');
|
||||
$validArtworkIds = DB::table('artworks')
|
||||
->whereNull('deleted_at')
|
||||
->pluck('id')
|
||||
->flip() // flip so we can use isset() for O(1) lookup
|
||||
->all();
|
||||
|
||||
$validUserIds = DB::table('users')
|
||||
->whereNull('deleted_at')
|
||||
->pluck('id')
|
||||
->flip()
|
||||
->all();
|
||||
|
||||
$this->info(sprintf(
|
||||
'Found %d artworks and %d users in new DB.',
|
||||
count($validArtworkIds),
|
||||
count($validUserIds)
|
||||
));
|
||||
|
||||
// Count legacy rows for progress bar
|
||||
$total = DB::connection('legacy')
|
||||
->table('users_opinions')
|
||||
->count();
|
||||
|
||||
$this->info("Legacy rows to process: {$total}");
|
||||
|
||||
if ($total === 0) {
|
||||
$this->warn('No legacy rows found. Nothing to do.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'imported' => 0,
|
||||
'skipped_score' => 0,
|
||||
'skipped_artwork' => 0,
|
||||
'skipped_user' => 0,
|
||||
'skipped_duplicate'=> 0,
|
||||
'updated_force' => 0,
|
||||
'errors' => 0,
|
||||
];
|
||||
|
||||
$affectedArtworkIds = [];
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% | imported: %imported% | skipped: %skipped%');
|
||||
$bar->setMessage('0', 'imported');
|
||||
$bar->setMessage('0', 'skipped');
|
||||
$bar->start();
|
||||
|
||||
DB::connection('legacy')
|
||||
->table('users_opinions')
|
||||
->orderBy('opinion_id')
|
||||
->chunk($chunk, function ($rows) use (
|
||||
&$stats,
|
||||
&$affectedArtworkIds,
|
||||
$validArtworkIds,
|
||||
$validUserIds,
|
||||
$dryRun,
|
||||
$force,
|
||||
$bar
|
||||
) {
|
||||
$inserts = [];
|
||||
$now = now();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$artworkId = (int) $row->artwork_id;
|
||||
$userId = (int) $row->author_id; // author_id = the voter
|
||||
$score = (int) $row->score;
|
||||
$postedAt = $row->post_date ?? $now;
|
||||
|
||||
// --- score → medal ---
|
||||
$medal = self::SCORE_MAP[$score] ?? null;
|
||||
if ($medal === null) {
|
||||
$stats['skipped_score']++;
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Artwork must exist in new DB ---
|
||||
if (! isset($validArtworkIds[$artworkId])) {
|
||||
$stats['skipped_artwork']++;
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- User must exist in new DB ---
|
||||
if (! isset($validUserIds[$userId])) {
|
||||
$stats['skipped_user']++;
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
if ($force) {
|
||||
// Upsert: update medal if row already exists
|
||||
$affected = DB::table('artwork_awards')
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('user_id', $userId)
|
||||
->update([
|
||||
'medal' => $medal,
|
||||
'weight' => ArtworkAward::WEIGHTS[$medal],
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if ($affected > 0) {
|
||||
$stats['updated_force']++;
|
||||
$affectedArtworkIds[$artworkId] = true;
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Skip if already exists
|
||||
if (
|
||||
DB::table('artwork_awards')
|
||||
->where('artwork_id', $artworkId)
|
||||
->where('user_id', $userId)
|
||||
->exists()
|
||||
) {
|
||||
$stats['skipped_duplicate']++;
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$inserts[] = [
|
||||
'artwork_id' => $artworkId,
|
||||
'user_id' => $userId,
|
||||
'medal' => $medal,
|
||||
'weight' => ArtworkAward::WEIGHTS[$medal],
|
||||
'created_at' => $postedAt,
|
||||
'updated_at' => $postedAt,
|
||||
];
|
||||
|
||||
$affectedArtworkIds[$artworkId] = true;
|
||||
}
|
||||
|
||||
$stats['imported']++;
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
// Bulk insert the batch (DB::table bypasses the observer intentionally;
|
||||
// stats are recalculated in bulk at the end for performance)
|
||||
if (! $dryRun && ! empty($inserts)) {
|
||||
try {
|
||||
DB::table('artwork_awards')->insert($inserts);
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback: insert one-by-one to isolate constraint violations
|
||||
foreach ($inserts as $row) {
|
||||
try {
|
||||
DB::table('artwork_awards')->insertOrIgnore([$row]);
|
||||
} catch (\Throwable) {
|
||||
$stats['errors']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$skippedTotal = $stats['skipped_score']
|
||||
+ $stats['skipped_artwork']
|
||||
+ $stats['skipped_user']
|
||||
+ $stats['skipped_duplicate'];
|
||||
|
||||
$bar->setMessage((string) $stats['imported'], 'imported');
|
||||
$bar->setMessage((string) $skippedTotal, 'skipped');
|
||||
});
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Recalculate stats for every affected artwork
|
||||
// -------------------------------------------------------------------------
|
||||
if (! $dryRun && ! $skipStats && ! empty($affectedArtworkIds)) {
|
||||
$artworkCount = count($affectedArtworkIds);
|
||||
$this->info("Recalculating award stats for {$artworkCount} artworks…");
|
||||
|
||||
$statsBar = $this->output->createProgressBar($artworkCount);
|
||||
$statsBar->start();
|
||||
|
||||
foreach (array_keys($affectedArtworkIds) as $artworkId) {
|
||||
try {
|
||||
$service->recalcStats($artworkId);
|
||||
} catch (\Throwable $e) {
|
||||
$this->newLine();
|
||||
$this->warn("Stats recalc failed for artwork #{$artworkId}: {$e->getMessage()}");
|
||||
}
|
||||
$statsBar->advance();
|
||||
}
|
||||
|
||||
$statsBar->finish();
|
||||
$this->newLine(2);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Summary
|
||||
// -------------------------------------------------------------------------
|
||||
$this->table(
|
||||
['Result', 'Count'],
|
||||
[
|
||||
['Imported (new rows)', $stats['imported']],
|
||||
['Forced updates', $stats['updated_force']],
|
||||
['Skipped – bad score', $stats['skipped_score']],
|
||||
['Skipped – artwork gone', $stats['skipped_artwork']],
|
||||
['Skipped – user gone', $stats['skipped_user']],
|
||||
['Skipped – duplicate', $stats['skipped_duplicate']],
|
||||
['Errors', $stats['errors']],
|
||||
]
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY-RUN] Nothing was written. Re-run without --dry-run to apply.');
|
||||
} else {
|
||||
$this->info('Migration complete.');
|
||||
}
|
||||
|
||||
return $stats['errors'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
266
app/Console/Commands/ImportLegacyComments.php
Normal file
266
app/Console/Commands/ImportLegacyComments.php
Normal file
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Migrates legacy `artworks_comments` (projekti_old_skinbase) into `artwork_comments`.
|
||||
*
|
||||
* Column mapping:
|
||||
* legacy.comment_id → artwork_comments.legacy_id (idempotency key)
|
||||
* legacy.artwork_id → artwork_comments.artwork_id
|
||||
* legacy.user_id → artwork_comments.user_id
|
||||
* legacy.description → artwork_comments.content
|
||||
* legacy.date + .time → artwork_comments.created_at / updated_at
|
||||
*
|
||||
* Ignored legacy columns: owner, author (username strings), owner_user_id
|
||||
*
|
||||
* Usage:
|
||||
* php artisan comments:import-legacy
|
||||
* php artisan comments:import-legacy --dry-run
|
||||
* php artisan comments:import-legacy --chunk=1000
|
||||
* php artisan comments:import-legacy --allow-guest-user=0 (import rows where user_id maps to 0 / not found, assigning a fallback user_id)
|
||||
*/
|
||||
class ImportLegacyComments extends Command
|
||||
{
|
||||
protected $signature = 'comments:import-legacy
|
||||
{--dry-run : Preview only — no writes to DB}
|
||||
{--chunk=500 : Rows to process per batch}
|
||||
{--skip-empty : Skip comments with empty/whitespace-only content}';
|
||||
|
||||
protected $description = 'Import legacy artworks_comments into artwork_comments';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$chunk = max(1, (int) $this->option('chunk'));
|
||||
$skipEmpty = (bool) $this->option('skip-empty');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY-RUN] No data will be written.');
|
||||
}
|
||||
|
||||
// Verify legacy connection
|
||||
try {
|
||||
DB::connection('legacy')->getPdo();
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Cannot connect to legacy database: ' . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! DB::connection('legacy')->getSchemaBuilder()->hasTable('artworks_comments')) {
|
||||
$this->error('Legacy table `artworks_comments` not found.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! DB::getSchemaBuilder()->hasColumn('artwork_comments', 'legacy_id')) {
|
||||
$this->error('Column `legacy_id` missing from `artwork_comments`. Run: php artisan migrate');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Pre-load valid artwork IDs and user IDs from new DB for O(1) lookup
|
||||
$this->info('Loading new-DB artwork and user ID sets…');
|
||||
|
||||
$validArtworkIds = DB::table('artworks')
|
||||
->whereNull('deleted_at')
|
||||
->pluck('id')
|
||||
->flip()
|
||||
->all();
|
||||
|
||||
$validUserIds = DB::table('users')
|
||||
->whereNull('deleted_at')
|
||||
->pluck('id')
|
||||
->flip()
|
||||
->all();
|
||||
|
||||
$this->info(sprintf(
|
||||
'Found %d artworks and %d users in new DB.',
|
||||
count($validArtworkIds),
|
||||
count($validUserIds)
|
||||
));
|
||||
|
||||
// Already-imported legacy IDs (to resume safely)
|
||||
$this->info('Loading already-imported legacy_ids…');
|
||||
$alreadyImported = DB::table('artwork_comments')
|
||||
->whereNotNull('legacy_id')
|
||||
->pluck('legacy_id')
|
||||
->flip()
|
||||
->all();
|
||||
|
||||
$this->info(sprintf('%d comments already imported (will be skipped).', count($alreadyImported)));
|
||||
|
||||
$total = DB::connection('legacy')->table('artworks_comments')->count();
|
||||
$this->info("Legacy rows to process: {$total}");
|
||||
|
||||
if ($total === 0) {
|
||||
$this->warn('No legacy rows found. Nothing to do.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'imported' => 0,
|
||||
'skipped_duplicate' => 0,
|
||||
'skipped_artwork' => 0,
|
||||
'skipped_user' => 0,
|
||||
'skipped_empty' => 0,
|
||||
'errors' => 0,
|
||||
];
|
||||
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% | imported: %imported% | skipped: %skipped%');
|
||||
$bar->setMessage('0', 'imported');
|
||||
$bar->setMessage('0', 'skipped');
|
||||
$bar->start();
|
||||
|
||||
DB::connection('legacy')
|
||||
->table('artworks_comments')
|
||||
->orderBy('comment_id')
|
||||
->chunk($chunk, function ($rows) use (
|
||||
&$stats,
|
||||
&$alreadyImported,
|
||||
$validArtworkIds,
|
||||
$validUserIds,
|
||||
$dryRun,
|
||||
$skipEmpty,
|
||||
$bar
|
||||
) {
|
||||
$inserts = [];
|
||||
$now = now();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$legacyId = (int) $row->comment_id;
|
||||
$artworkId = (int) $row->artwork_id;
|
||||
$userId = (int) $row->user_id;
|
||||
$content = trim((string) ($row->description ?? ''));
|
||||
|
||||
// --- Already imported ---
|
||||
if (isset($alreadyImported[$legacyId])) {
|
||||
$stats['skipped_duplicate']++;
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Content ---
|
||||
if ($skipEmpty && $content === '') {
|
||||
$stats['skipped_empty']++;
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Replace empty content with a placeholder so NOT NULL is satisfied
|
||||
if ($content === '') {
|
||||
$content = '[no content]';
|
||||
}
|
||||
|
||||
// --- Artwork must exist ---
|
||||
if (! isset($validArtworkIds[$artworkId])) {
|
||||
$stats['skipped_artwork']++;
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- User must exist ---
|
||||
if (! isset($validUserIds[$userId])) {
|
||||
$stats['skipped_user']++;
|
||||
$bar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Build timestamp from separate date + time columns ---
|
||||
$createdAt = $this->buildTimestamp($row->date, $row->time, $now);
|
||||
|
||||
if (! $dryRun) {
|
||||
$inserts[] = [
|
||||
'legacy_id' => $legacyId,
|
||||
'artwork_id' => $artworkId,
|
||||
'user_id' => $userId,
|
||||
'content' => $content,
|
||||
'is_approved' => 1,
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => $createdAt,
|
||||
'deleted_at' => null,
|
||||
];
|
||||
|
||||
$alreadyImported[$legacyId] = true;
|
||||
}
|
||||
|
||||
$stats['imported']++;
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
if (! $dryRun && ! empty($inserts)) {
|
||||
try {
|
||||
DB::table('artwork_comments')->insert($inserts);
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback: row-by-row with ignore on unique violations
|
||||
foreach ($inserts as $row) {
|
||||
try {
|
||||
DB::table('artwork_comments')->insertOrIgnore([$row]);
|
||||
} catch (\Throwable) {
|
||||
$stats['errors']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$skippedTotal = $stats['skipped_duplicate']
|
||||
+ $stats['skipped_artwork']
|
||||
+ $stats['skipped_user']
|
||||
+ $stats['skipped_empty'];
|
||||
|
||||
$bar->setMessage((string) $stats['imported'], 'imported');
|
||||
$bar->setMessage((string) $skippedTotal, 'skipped');
|
||||
});
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Summary
|
||||
// -------------------------------------------------------------------------
|
||||
$this->table(
|
||||
['Result', 'Count'],
|
||||
[
|
||||
['Imported', $stats['imported']],
|
||||
['Skipped – already imported', $stats['skipped_duplicate']],
|
||||
['Skipped – artwork gone', $stats['skipped_artwork']],
|
||||
['Skipped – user gone', $stats['skipped_user']],
|
||||
['Skipped – empty content', $stats['skipped_empty']],
|
||||
['Errors', $stats['errors']],
|
||||
]
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY-RUN] Nothing was written. Re-run without --dry-run to apply.');
|
||||
} else {
|
||||
$this->info('Migration complete.');
|
||||
}
|
||||
|
||||
return $stats['errors'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine a legacy `date` (DATE) and `time` (TIME) column into a single datetime string.
|
||||
* Falls back to $fallback when both are null.
|
||||
*/
|
||||
private function buildTimestamp(mixed $date, mixed $time, \Illuminate\Support\Carbon $fallback): string
|
||||
{
|
||||
if (! $date) {
|
||||
return $fallback->toDateTimeString();
|
||||
}
|
||||
|
||||
$datePart = substr((string) $date, 0, 10); // '2000-09-13'
|
||||
$timePart = $time ? substr((string) $time, 0, 8) : '00:00:00'; // '09:34:27'
|
||||
|
||||
// Sanity-check: MySQL TIME can be negative or > 24h for intervals — clamp to midnight
|
||||
if (! preg_match('/^\d{2}:\d{2}:\d{2}$/', $timePart) || $timePart < '00:00:00') {
|
||||
$timePart = '00:00:00';
|
||||
}
|
||||
|
||||
return $datePart . ' ' . $timePart;
|
||||
}
|
||||
}
|
||||
30
app/Console/Commands/RebuildArtworkSearchIndex.php
Normal file
30
app/Console/Commands/RebuildArtworkSearchIndex.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ArtworkSearchIndexer;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RebuildArtworkSearchIndex extends Command
|
||||
{
|
||||
protected $signature = 'artworks:search-rebuild {--chunk=500 : Number of artworks per chunk}';
|
||||
protected $description = 'Re-queue all artworks for Meilisearch indexing (non-blocking, chunk-based).';
|
||||
|
||||
public function __construct(private readonly ArtworkSearchIndexer $indexer)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunk = (int) $this->option('chunk');
|
||||
|
||||
$this->info("Dispatching index jobs in chunks of {$chunk}…");
|
||||
$this->indexer->rebuildAll($chunk);
|
||||
$this->info('All jobs dispatched. Workers will process them asynchronously.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
|
||||
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
|
||||
use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
||||
use App\Console\Commands\EvaluateFeedWeightsCommand;
|
||||
use App\Console\Commands\AiTagArtworksCommand;
|
||||
use App\Console\Commands\CompareFeedAbCommand;
|
||||
use App\Uploads\Commands\CleanupUploadsCommand;
|
||||
|
||||
@@ -33,6 +34,7 @@ class Kernel extends ConsoleKernel
|
||||
AggregateFeedAnalyticsCommand::class,
|
||||
EvaluateFeedWeightsCommand::class,
|
||||
CompareFeedAbCommand::class,
|
||||
AiTagArtworksCommand::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
121
app/Http/Controllers/Api/ArtworkAwardController.php
Normal file
121
app/Http/Controllers/Api/ArtworkAwardController.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkAward;
|
||||
use App\Services\ArtworkAwardService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class ArtworkAwardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkAwardService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* POST /api/artworks/{id}/award
|
||||
* Award the artwork with a medal.
|
||||
*/
|
||||
public function store(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$artwork = Artwork::findOrFail($id);
|
||||
|
||||
$this->authorize('award', [ArtworkAward::class, $artwork]);
|
||||
|
||||
$data = $request->validate([
|
||||
'medal' => ['required', 'string', 'in:gold,silver,bronze'],
|
||||
]);
|
||||
|
||||
$award = $this->service->award($artwork, $user, $data['medal']);
|
||||
|
||||
return response()->json(
|
||||
$this->buildPayload($artwork->id, $user->id),
|
||||
201
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/artworks/{id}/award
|
||||
* Change an existing award medal.
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$artwork = Artwork::findOrFail($id);
|
||||
|
||||
$existingAward = ArtworkAward::where('artwork_id', $artwork->id)
|
||||
->where('user_id', $user->id)
|
||||
->firstOrFail();
|
||||
|
||||
$this->authorize('change', $existingAward);
|
||||
|
||||
$data = $request->validate([
|
||||
'medal' => ['required', 'string', 'in:gold,silver,bronze'],
|
||||
]);
|
||||
|
||||
$award = $this->service->changeAward($artwork, $user, $data['medal']);
|
||||
|
||||
return response()->json($this->buildPayload($artwork->id, $user->id));
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/artworks/{id}/award
|
||||
* Remove the user's award for this artwork.
|
||||
*/
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$artwork = Artwork::findOrFail($id);
|
||||
|
||||
$existingAward = ArtworkAward::where('artwork_id', $artwork->id)
|
||||
->where('user_id', $user->id)
|
||||
->firstOrFail();
|
||||
|
||||
$this->authorize('remove', $existingAward);
|
||||
|
||||
$this->service->removeAward($artwork, $user);
|
||||
|
||||
return response()->json($this->buildPayload($artwork->id, $user->id));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/artworks/{id}/awards
|
||||
* Return award stats + viewer's current award.
|
||||
*/
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::findOrFail($id);
|
||||
|
||||
return response()->json($this->buildPayload($artwork->id, $request->user()?->id));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// All authorization is delegated to ArtworkAwardPolicy via $this->authorize().
|
||||
|
||||
private function buildPayload(int $artworkId, ?int $userId): array
|
||||
{
|
||||
$stat = \App\Models\ArtworkAwardStat::find($artworkId);
|
||||
|
||||
$userAward = $userId
|
||||
? ArtworkAward::where('artwork_id', $artworkId)
|
||||
->where('user_id', $userId)
|
||||
->value('medal')
|
||||
: null;
|
||||
|
||||
return [
|
||||
'awards' => [
|
||||
'gold' => $stat?->gold_count ?? 0,
|
||||
'silver' => $stat?->silver_count ?? 0,
|
||||
'bronze' => $stat?->bronze_count ?? 0,
|
||||
'score' => $stat?->score_total ?? 0,
|
||||
],
|
||||
'viewer_award' => $userAward,
|
||||
];
|
||||
}
|
||||
}
|
||||
71
app/Http/Controllers/Api/ArtworkNavigationController.php
Normal file
71
app/Http/Controllers/Api/ArtworkNavigationController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ArtworkNavigationController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/artworks/navigation/{id}
|
||||
*
|
||||
* Returns prev/next published artworks by the same author.
|
||||
*/
|
||||
public function neighbors(int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::published()
|
||||
->select(['id', 'user_id', 'title', 'slug'])
|
||||
->find($id);
|
||||
|
||||
if (! $artwork) {
|
||||
return response()->json([
|
||||
'prev_id' => null, 'next_id' => null,
|
||||
'prev_url' => null, 'next_url' => null,
|
||||
'prev_slug' => null, 'next_slug' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$scope = Artwork::published()
|
||||
->select(['id', 'title', 'slug'])
|
||||
->where('user_id', $artwork->user_id);
|
||||
|
||||
$prev = (clone $scope)->where('id', '<', $id)->orderByDesc('id')->first();
|
||||
$next = (clone $scope)->where('id', '>', $id)->orderBy('id')->first();
|
||||
|
||||
$prevSlug = $prev ? (Str::slug($prev->slug ?: $prev->title) ?: (string) $prev->id) : null;
|
||||
$nextSlug = $next ? (Str::slug($next->slug ?: $next->title) ?: (string) $next->id) : null;
|
||||
|
||||
return response()->json([
|
||||
'prev_id' => $prev?->id,
|
||||
'next_id' => $next?->id,
|
||||
'prev_url' => $prev ? url('/art/' . $prev->id . '/' . $prevSlug) : null,
|
||||
'next_url' => $next ? url('/art/' . $next->id . '/' . $nextSlug) : null,
|
||||
'prev_slug' => $prevSlug,
|
||||
'next_slug' => $nextSlug,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/artworks/{id}/page
|
||||
*
|
||||
* Returns full artwork resource by numeric ID for client-side (no-reload) navigation.
|
||||
*/
|
||||
public function pageData(int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::with(['user.profile', 'categories.contentType', 'tags', 'stats'])
|
||||
->published()
|
||||
->find($id);
|
||||
|
||||
if (! $artwork) {
|
||||
return response()->json(['error' => 'Not found'], 404);
|
||||
}
|
||||
|
||||
$resource = (new ArtworkResource($artwork))->toArray(request());
|
||||
|
||||
return response()->json($resource);
|
||||
}
|
||||
}
|
||||
109
app/Http/Controllers/Api/Search/ArtworkSearchController.php
Normal file
109
app/Http/Controllers/Api/Search/ArtworkSearchController.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Search;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkListResource;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Artwork search endpoints powered by Meilisearch.
|
||||
*
|
||||
* GET /api/search/artworks?q=&tags[]=&category=&orientation=&sort=
|
||||
* GET /api/search/artworks/tag/{slug}
|
||||
* GET /api/search/artworks/category/{cat}
|
||||
* GET /api/search/artworks/related/{id}
|
||||
*/
|
||||
class ArtworkSearchController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkSearchService $search
|
||||
) {}
|
||||
|
||||
/**
|
||||
* GET /api/search/artworks
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'q' => ['nullable', 'string', 'max:200'],
|
||||
'tags' => ['nullable', 'array', 'max:10'],
|
||||
'tags.*' => ['string', 'max:80'],
|
||||
'category' => ['nullable', 'string', 'max:80'],
|
||||
'orientation' => ['nullable', 'in:landscape,portrait,square'],
|
||||
'resolution' => ['nullable', 'string', 'max:20'],
|
||||
'author_id' => ['nullable', 'integer', 'min:1'],
|
||||
'sort' => ['nullable', 'string', 'regex:/^(created_at|downloads|likes|views):(asc|desc)$/'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
]);
|
||||
|
||||
$results = $this->search->search(
|
||||
q: (string) ($validated['q'] ?? ''),
|
||||
filters: array_filter([
|
||||
'tags' => $validated['tags'] ?? [],
|
||||
'category' => $validated['category'] ?? null,
|
||||
'orientation' => $validated['orientation'] ?? null,
|
||||
'resolution' => $validated['resolution'] ?? null,
|
||||
'author_id' => $validated['author_id'] ?? null,
|
||||
'sort' => $validated['sort'] ?? null,
|
||||
]),
|
||||
perPage: (int) ($validated['per_page'] ?? 24),
|
||||
);
|
||||
|
||||
// Eager-load relations needed by ArtworkListResource
|
||||
$results->getCollection()->loadMissing(['user', 'categories.contentType']);
|
||||
|
||||
return ArtworkListResource::collection($results)->response();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/search/artworks/tag/{slug}
|
||||
*/
|
||||
public function byTag(Request $request, string $slug): JsonResponse
|
||||
{
|
||||
$tag = Tag::where('slug', $slug)->first();
|
||||
|
||||
if (! $tag) {
|
||||
return response()->json(['message' => 'Tag not found.'], 404);
|
||||
}
|
||||
|
||||
$results = $this->search->byTag($slug, (int) $request->query('per_page', 24));
|
||||
|
||||
return response()->json([
|
||||
'tag' => ['id' => $tag->id, 'name' => $tag->name, 'slug' => $tag->slug],
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/search/artworks/category/{cat}
|
||||
*/
|
||||
public function byCategory(Request $request, string $cat): JsonResponse
|
||||
{
|
||||
$results = $this->search->byCategory($cat, (int) $request->query('per_page', 24));
|
||||
|
||||
return response()->json($results);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/search/artworks/related/{id}
|
||||
*/
|
||||
public function related(int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::with(['tags'])->find($id);
|
||||
|
||||
if (! $artwork) {
|
||||
return response()->json(['message' => 'Artwork not found.'], 404);
|
||||
}
|
||||
|
||||
$results = $this->search->related($artwork, 12);
|
||||
|
||||
return response()->json($results);
|
||||
}
|
||||
}
|
||||
@@ -9,44 +9,49 @@ use App\Http\Requests\Tags\PopularTagsRequest;
|
||||
use App\Http\Requests\Tags\TagSearchRequest;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class TagController extends Controller
|
||||
{
|
||||
public function search(TagSearchRequest $request): JsonResponse
|
||||
{
|
||||
$q = (string) ($request->validated()['q'] ?? '');
|
||||
$q = trim($q);
|
||||
$q = trim((string) ($request->validated()['q'] ?? ''));
|
||||
|
||||
$query = Tag::query()->where('is_active', true);
|
||||
if ($q !== '') {
|
||||
$query->where(function ($sub) use ($q): void {
|
||||
$sub->where('name', 'like', $q . '%')
|
||||
->orWhere('slug', 'like', $q . '%');
|
||||
});
|
||||
}
|
||||
// Short results cached for 2 min; empty-query (popular suggestions) for 5 min.
|
||||
$ttl = $q === '' ? 300 : 120;
|
||||
$cacheKey = 'tags.search.' . ($q === '' ? '__empty__' : md5($q));
|
||||
|
||||
$tags = $query
|
||||
->orderByDesc('usage_count')
|
||||
->limit(20)
|
||||
->get(['id', 'name', 'slug', 'usage_count']);
|
||||
$data = Cache::remember($cacheKey, $ttl, function () use ($q): mixed {
|
||||
$query = Tag::query()->where('is_active', true);
|
||||
if ($q !== '') {
|
||||
$query->where(function ($sub) use ($q): void {
|
||||
$sub->where('name', 'like', $q . '%')
|
||||
->orWhere('slug', 'like', $q . '%');
|
||||
});
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $tags,
|
||||
]);
|
||||
return $query
|
||||
->orderByDesc('usage_count')
|
||||
->limit(20)
|
||||
->get(['id', 'name', 'slug', 'usage_count']);
|
||||
});
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
}
|
||||
|
||||
public function popular(PopularTagsRequest $request): JsonResponse
|
||||
{
|
||||
$limit = (int) ($request->validated()['limit'] ?? 20);
|
||||
$limit = (int) ($request->validated()['limit'] ?? 20);
|
||||
$cacheKey = 'tags.popular.' . $limit;
|
||||
|
||||
$tags = Tag::query()
|
||||
->where('is_active', true)
|
||||
->orderByDesc('usage_count')
|
||||
->limit($limit)
|
||||
->get(['id', 'name', 'slug', 'usage_count']);
|
||||
$data = Cache::remember($cacheKey, 300, function () use ($limit): mixed {
|
||||
return Tag::query()
|
||||
->where('is_active', true)
|
||||
->orderByDesc('usage_count')
|
||||
->limit($limit)
|
||||
->get(['id', 'name', 'slug', 'usage_count']);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $tags,
|
||||
]);
|
||||
return response()->json(['data' => $data]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,24 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
class InterviewController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
try {
|
||||
$interviews = DB::table('interviews as i')
|
||||
->leftJoin('users as u', 'u.username', '=', 'i.username')
|
||||
->select('i.id', 'i.headline', 'i.username', 'u.id as user_id', 'u.name as uname', 'u.icon')
|
||||
->orderByDesc('i.id')
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
$interviews = collect();
|
||||
}
|
||||
|
||||
return view('legacy.interviews', [
|
||||
'interviews' => $interviews,
|
||||
'page_title' => 'Interviews',
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, $id, $slug = null)
|
||||
{
|
||||
$id = (int) $id;
|
||||
|
||||
@@ -49,6 +49,6 @@ class LatestCommentsController extends Controller
|
||||
|
||||
$page_title = 'Latest Comments';
|
||||
|
||||
return view('community.latest-comments', compact('page_title', 'comments'));
|
||||
return view('web.comments.latest', compact('page_title', 'comments'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,10 +36,11 @@ class LatestController extends Controller
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
'published_at' => $artwork->published_at, // required by CursorPaginator
|
||||
];
|
||||
});
|
||||
|
||||
return view('community.latest-artworks', [
|
||||
return view('web.uploads.latest', [
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'Latest Artworks',
|
||||
]);
|
||||
|
||||
@@ -44,6 +44,6 @@ class MembersController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
return view('web.browse', compact('page_title', 'artworks'));
|
||||
return view('web.members.photos', compact('page_title', 'artworks'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,26 +14,21 @@ class MonthlyCommentatorsController extends Controller
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$query = DB::table('artwork_comments as t1')
|
||||
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id')
|
||||
->leftJoin('country as c', 't2.country', '=', 'c.id')
|
||||
->leftJoin('users as t2', 't1.user_id', '=', 't2.id')
|
||||
->where('t1.user_id', '>', 0)
|
||||
->whereRaw("DATE_SUB(CURDATE(), INTERVAL 30 DAY) <= t1.date")
|
||||
->whereRaw('t1.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)')
|
||||
->select(
|
||||
't2.user_id',
|
||||
't2.uname',
|
||||
't2.user_type',
|
||||
't2.country',
|
||||
'c.name as country_name',
|
||||
'c.flag as country_flag',
|
||||
't2.id as user_id',
|
||||
DB::raw('COALESCE(t2.username, t2.name, "User") as uname'),
|
||||
DB::raw('COUNT(*) as num_comments')
|
||||
)
|
||||
->groupBy('t1.user_id')
|
||||
->groupBy('t2.id')
|
||||
->orderByDesc('num_comments');
|
||||
|
||||
$rows = $query->paginate($hits)->withQueryString();
|
||||
|
||||
$page_title = 'Monthly Top Commentators';
|
||||
|
||||
return view('user.monthly-commentators', compact('page_title', 'rows'));
|
||||
return view('web.comments.monthly', compact('page_title', 'rows'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,21 @@ namespace App\Http\Controllers\User;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ProfileUpdateRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ProfileComment;
|
||||
use App\Models\User;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\ThumbnailService;
|
||||
use App\Services\UsernameApprovalService;
|
||||
use App\Support\AvatarUrl;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
@@ -63,6 +69,66 @@ class ProfileController extends Controller
|
||||
return redirect()->route('profile.show', ['username' => UsernamePolicy::normalize($username)], 301);
|
||||
}
|
||||
|
||||
/** Toggle follow/unfollow for the profile of $username (auth required). */
|
||||
public function toggleFollow(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
$target = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->firstOrFail();
|
||||
|
||||
$viewerId = Auth::id();
|
||||
|
||||
if ($viewerId === $target->id) {
|
||||
return response()->json(['error' => 'Cannot follow yourself.'], 422);
|
||||
}
|
||||
|
||||
$exists = DB::table('user_followers')
|
||||
->where('user_id', $target->id)
|
||||
->where('follower_id', $viewerId)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
DB::table('user_followers')
|
||||
->where('user_id', $target->id)
|
||||
->where('follower_id', $viewerId)
|
||||
->delete();
|
||||
$following = false;
|
||||
} else {
|
||||
DB::table('user_followers')->insertOrIgnore([
|
||||
'user_id' => $target->id,
|
||||
'follower_id'=> $viewerId,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$following = true;
|
||||
}
|
||||
|
||||
$count = DB::table('user_followers')->where('user_id', $target->id)->count();
|
||||
|
||||
return response()->json([
|
||||
'following' => $following,
|
||||
'follower_count' => $count,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Store a comment on a user profile (auth required). */
|
||||
public function storeComment(Request $request, string $username): RedirectResponse
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
$target = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->firstOrFail();
|
||||
|
||||
$request->validate([
|
||||
'body' => ['required', 'string', 'min:2', 'max:2000'],
|
||||
]);
|
||||
|
||||
ProfileComment::create([
|
||||
'profile_user_id' => $target->id,
|
||||
'author_user_id' => Auth::id(),
|
||||
'body' => $request->input('body'),
|
||||
]);
|
||||
|
||||
return Redirect::route('profile.show', ['username' => strtolower((string) $target->username)])
|
||||
->with('status', 'Comment posted!');
|
||||
}
|
||||
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
return view('profile.edit', [
|
||||
@@ -256,38 +322,211 @@ class ProfileController extends Controller
|
||||
|
||||
private function renderUserProfile(Request $request, User $user)
|
||||
{
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
$perPage = 24;
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
$viewer = Auth::user();
|
||||
$perPage = 24;
|
||||
|
||||
// ── Artworks (cursor-paginated) ──────────────────────────────────────
|
||||
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage)
|
||||
->through(function (Artwork $art) {
|
||||
$present = \App\Services\ThumbnailPresenter::present($art, 'md');
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
return (object) [
|
||||
'id' => $art->id,
|
||||
'name' => $art->title,
|
||||
'picture' => $art->file_name,
|
||||
'datum' => $art->published_at,
|
||||
'published_at' => $art->published_at, // required by cursor paginator (orders by this column)
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $art->user->name ?? 'Skinbase',
|
||||
'username' => $art->user->username ?? null,
|
||||
'user_id' => $art->user_id,
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
];
|
||||
});
|
||||
|
||||
return (object) [
|
||||
'id' => $art->id,
|
||||
'name' => $art->title,
|
||||
'picture' => $art->file_name,
|
||||
'datum' => $art->published_at,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $art->user->name ?? 'Skinbase',
|
||||
];
|
||||
});
|
||||
// ── Featured artworks for this user ─────────────────────────────────
|
||||
$featuredArtworks = collect();
|
||||
if (Schema::hasTable('artwork_features')) {
|
||||
$featuredArtworks = DB::table('artwork_features as af')
|
||||
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
|
||||
->where('a.user_id', $user->id)
|
||||
->where('af.is_active', true)
|
||||
->whereNull('af.deleted_at')
|
||||
->whereNull('a.deleted_at')
|
||||
->where('a.is_public', true)
|
||||
->where('a.is_approved', true)
|
||||
->orderByDesc('af.featured_at')
|
||||
->limit(3)
|
||||
->select([
|
||||
'a.id', 'a.title as name', 'a.hash', 'a.thumb_ext',
|
||||
'a.width', 'a.height', 'af.label', 'af.featured_at',
|
||||
])
|
||||
->get()
|
||||
->map(function ($row) {
|
||||
$thumbUrl = ($row->hash && $row->thumb_ext)
|
||||
? ThumbnailService::fromHash($row->hash, $row->thumb_ext, 'md')
|
||||
: '/images/placeholder.jpg';
|
||||
return (object) [
|
||||
'id' => $row->id,
|
||||
'name' => $row->name,
|
||||
'thumb' => $thumbUrl,
|
||||
'label' => $row->label,
|
||||
'featured_at' => $row->featured_at,
|
||||
'width' => $row->width,
|
||||
'height' => $row->height,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
$legacyUser = (object) [
|
||||
'user_id' => $user->id,
|
||||
'uname' => $user->username ?? $user->name,
|
||||
'name' => $user->name,
|
||||
'real_name' => $user->name,
|
||||
'icon' => DB::table('user_profiles')->where('user_id', $user->id)->value('avatar_hash'),
|
||||
'about_me' => $user->bio ?? null,
|
||||
];
|
||||
// ── Favourites ───────────────────────────────────────────────────────
|
||||
$favourites = collect();
|
||||
if (Schema::hasTable('user_favorites')) {
|
||||
$favourites = 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')
|
||||
->limit(12)
|
||||
->select(['a.id', 'a.title as name', 'a.hash', 'a.thumb_ext', 'a.width', 'a.height', 'a.user_id'])
|
||||
->get()
|
||||
->map(function ($row) {
|
||||
$thumbUrl = ($row->hash && $row->thumb_ext)
|
||||
? ThumbnailService::fromHash($row->hash, $row->thumb_ext, 'sm')
|
||||
: '/images/placeholder.jpg';
|
||||
return (object) [
|
||||
'id' => $row->id,
|
||||
'name' => $row->name,
|
||||
'thumb' => $thumbUrl,
|
||||
'width' => $row->width,
|
||||
'height'=> $row->height,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
// ── Statistics ───────────────────────────────────────────────────────
|
||||
$stats = null;
|
||||
if (Schema::hasTable('user_statistics')) {
|
||||
$stats = DB::table('user_statistics')->where('user_id', $user->id)->first();
|
||||
}
|
||||
|
||||
// ── Social links ─────────────────────────────────────────────────────
|
||||
$socialLinks = collect();
|
||||
if (Schema::hasTable('user_social_links')) {
|
||||
$socialLinks = DB::table('user_social_links')
|
||||
->where('user_id', $user->id)
|
||||
->get()
|
||||
->keyBy('platform');
|
||||
}
|
||||
|
||||
// ── Follower data ────────────────────────────────────────────────────
|
||||
$followerCount = 0;
|
||||
$recentFollowers = collect();
|
||||
$viewerIsFollowing = false;
|
||||
|
||||
if (Schema::hasTable('user_followers')) {
|
||||
$followerCount = DB::table('user_followers')->where('user_id', $user->id)->count();
|
||||
|
||||
$recentFollowers = DB::table('user_followers as uf')
|
||||
->join('users as u', 'u.id', '=', 'uf.follower_id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->where('uf.user_id', $user->id)
|
||||
->whereNull('u.deleted_at')
|
||||
->orderByDesc('uf.created_at')
|
||||
->limit(10)
|
||||
->select(['u.id', 'u.username', 'u.name', 'up.avatar_hash', 'uf.created_at as followed_at'])
|
||||
->get()
|
||||
->map(fn ($row) => (object) [
|
||||
'id' => $row->id,
|
||||
'username' => $row->username,
|
||||
'uname' => $row->username ?? $row->name,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
|
||||
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
|
||||
'followed_at' => $row->followed_at,
|
||||
]);
|
||||
|
||||
if ($viewer && $viewer->id !== $user->id) {
|
||||
$viewerIsFollowing = DB::table('user_followers')
|
||||
->where('user_id', $user->id)
|
||||
->where('follower_id', $viewer->id)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Profile comments ─────────────────────────────────────────────────
|
||||
$profileComments = collect();
|
||||
if (Schema::hasTable('profile_comments')) {
|
||||
$profileComments = DB::table('profile_comments as pc')
|
||||
->join('users as u', 'u.id', '=', 'pc.author_user_id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->where('pc.profile_user_id', $user->id)
|
||||
->where('pc.is_active', true)
|
||||
->whereNull('u.deleted_at')
|
||||
->orderByDesc('pc.created_at')
|
||||
->limit(10)
|
||||
->select([
|
||||
'pc.id', 'pc.body', 'pc.created_at',
|
||||
'u.id as author_id', 'u.username as author_username', 'u.name as author_name',
|
||||
'up.avatar_hash as author_avatar_hash', 'up.signature as author_signature',
|
||||
])
|
||||
->get()
|
||||
->map(fn ($row) => (object) [
|
||||
'id' => $row->id,
|
||||
'body' => $row->body,
|
||||
'created_at' => $row->created_at,
|
||||
'author_id' => $row->author_id,
|
||||
'author_name' => $row->author_username ?? $row->author_name ?? 'Unknown',
|
||||
'author_profile_url' => '/@' . strtolower((string) ($row->author_username ?? $row->author_id)),
|
||||
'author_avatar' => AvatarUrl::forUser((int) $row->author_id, $row->author_avatar_hash, 50),
|
||||
'author_signature' => $row->author_signature,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Profile data ─────────────────────────────────────────────────────
|
||||
$profile = $user->profile;
|
||||
|
||||
// ── Country name (from old country_list table if available) ──────────
|
||||
$countryName = null;
|
||||
if ($profile?->country_code) {
|
||||
if (Schema::hasTable('country_list')) {
|
||||
$countryName = DB::table('country_list')
|
||||
->where('country_code', $profile->country_code)
|
||||
->value('country_name');
|
||||
}
|
||||
$countryName = $countryName ?? strtoupper((string) $profile->country_code);
|
||||
}
|
||||
|
||||
// ── Increment profile views (async-safe, ignore errors) ──────────────
|
||||
if (! $isOwner) {
|
||||
try {
|
||||
DB::table('user_statistics')
|
||||
->updateOrInsert(
|
||||
['user_id' => $user->id],
|
||||
['profile_views' => DB::raw('COALESCE(profile_views, 0) + 1'), 'updated_at' => now()]
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
return response()->view('legacy.profile', [
|
||||
'user' => $legacyUser,
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'Profile: ' . ($legacyUser->uname ?? ''),
|
||||
'page_canonical' => url('/@' . strtolower((string) ($user->username ?? ''))),
|
||||
'user' => $user,
|
||||
'profile' => $profile,
|
||||
'artworks' => $artworks,
|
||||
'featuredArtworks' => $featuredArtworks,
|
||||
'favourites' => $favourites,
|
||||
'stats' => $stats,
|
||||
'socialLinks' => $socialLinks,
|
||||
'followerCount' => $followerCount,
|
||||
'recentFollowers' => $recentFollowers,
|
||||
'viewerIsFollowing' => $viewerIsFollowing,
|
||||
'profileComments' => $profileComments,
|
||||
'countryName' => $countryName,
|
||||
'isOwner' => $isOwner,
|
||||
'page_title' => 'Profile: ' . ($user->username ?? $user->name ?? ''),
|
||||
'page_canonical' => url('/@' . strtolower((string) ($user->username ?? ''))),
|
||||
'page_meta_description' => 'View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,6 @@ class TodayDownloadsController extends Controller
|
||||
|
||||
$page_title = 'Today Downloaded Artworks';
|
||||
|
||||
return view('web.browse', ['page_title' => $page_title, 'artworks' => $paginator]);
|
||||
return view('web.downloads.today', ['page_title' => $page_title, 'artworks' => $paginator]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ class TodayInHistoryController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
return view('user.today-in-history', [
|
||||
return view('legacy.today-in-history', [
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'Popular on this day in history',
|
||||
]);
|
||||
|
||||
@@ -21,7 +21,6 @@ class TopAuthorsController extends Controller
|
||||
}
|
||||
|
||||
$sub = Artwork::query()
|
||||
->select('artworks.user_id')
|
||||
->join('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
@@ -52,6 +51,6 @@ class TopAuthorsController extends Controller
|
||||
|
||||
$page_title = 'Top Authors';
|
||||
|
||||
return view('user.top-authors', compact('page_title', 'authors', 'metric'));
|
||||
return view('web.authors.top', compact('page_title', 'authors', 'metric'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,6 @@ class TopFavouritesController extends Controller
|
||||
|
||||
$page_title = 'Top Favourites';
|
||||
|
||||
return view('user.top-favourites', ['page_title' => $page_title, 'artworks' => $paginator]);
|
||||
return view('legacy.top-favourites', ['page_title' => $page_title, 'artworks' => $paginator]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Web;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -102,6 +103,27 @@ final class ArtworkPageController extends Controller
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$comments = ArtworkComment::with(['user.profile'])
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('is_approved', true)
|
||||
->orderBy('created_at')
|
||||
->limit(500)
|
||||
->get()
|
||||
->map(fn(ArtworkComment $c) => [
|
||||
'id' => $c->id,
|
||||
'content' => (string) $c->content,
|
||||
'created_at' => $c->created_at?->toIsoString(),
|
||||
'user' => [
|
||||
'id' => $c->user?->id,
|
||||
'name' => $c->user?->name,
|
||||
'username' => $c->user?->username,
|
||||
'profile_url' => $c->user?->username ? '/@' . $c->user->username : null,
|
||||
'avatar_url' => $c->user?->profile?->avatar_url,
|
||||
],
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return view('artworks.show', [
|
||||
'artwork' => $artwork,
|
||||
'artworkData' => $artworkData,
|
||||
@@ -111,6 +133,7 @@ final class ArtworkPageController extends Controller
|
||||
'presentSq' => $thumbSq,
|
||||
'meta' => $meta,
|
||||
'relatedItems' => $related,
|
||||
'comments' => $comments,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Pagination\AbstractPaginator;
|
||||
@@ -15,8 +16,17 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
{
|
||||
private const CONTENT_TYPE_SLUGS = ['photography', 'wallpapers', 'skins', 'other'];
|
||||
|
||||
public function __construct(private ArtworkService $artworks)
|
||||
{
|
||||
private const SORT_MAP = [
|
||||
'latest' => 'created_at:desc',
|
||||
'popular' => 'views:desc',
|
||||
'liked' => 'likes:desc',
|
||||
'downloads' => 'downloads:desc',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private ArtworkService $artworks,
|
||||
private ArtworkSearchService $search,
|
||||
) {
|
||||
}
|
||||
|
||||
public function browse(Request $request)
|
||||
@@ -24,7 +34,10 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
$sort = (string) $request->query('sort', 'latest');
|
||||
$perPage = $this->resolvePerPage($request);
|
||||
|
||||
$artworks = $this->artworks->browsePublicArtworks($perPage, $sort);
|
||||
$artworks = Artwork::search('')->options([
|
||||
'filter' => 'is_public = true AND is_approved = true',
|
||||
'sort' => [self::SORT_MAP[$sort] ?? 'created_at:desc'],
|
||||
])->paginate($perPage);
|
||||
$seo = $this->buildPaginationSeo($request, url('/browse'), $artworks);
|
||||
|
||||
$mainCategories = $this->mainCategories();
|
||||
@@ -69,7 +82,10 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
|
||||
$normalizedPath = trim((string) $path, '/');
|
||||
if ($normalizedPath === '') {
|
||||
$artworks = $this->artworks->getArtworksByContentType($contentSlug, $perPage, $sort);
|
||||
$artworks = Artwork::search('')->options([
|
||||
'filter' => 'is_public = true AND is_approved = true AND content_type = "' . $contentSlug . '"',
|
||||
'sort' => [self::SORT_MAP[$sort] ?? 'created_at:desc'],
|
||||
])->paginate($perPage);
|
||||
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug), $artworks);
|
||||
|
||||
return view('gallery.index', [
|
||||
@@ -98,7 +114,10 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$artworks = $this->artworks->getArtworksByCategoryPath(array_merge([$contentSlug], $segments), $perPage, $sort);
|
||||
$artworks = Artwork::search('')->options([
|
||||
'filter' => 'is_public = true AND is_approved = true AND category = "' . $category->slug . '"',
|
||||
'sort' => [self::SORT_MAP[$sort] ?? 'created_at:desc'],
|
||||
])->paginate($perPage);
|
||||
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug . '/' . strtolower($category->full_slug_path)), $artworks);
|
||||
|
||||
$subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get();
|
||||
|
||||
@@ -17,6 +17,11 @@ class CategoryController extends Controller
|
||||
$this->artworkService = $artworkService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
return $this->browseCategories();
|
||||
}
|
||||
|
||||
public function show(Request $request, $id, $slug = null, $group = null)
|
||||
{
|
||||
$path = trim($request->path(), '/');
|
||||
|
||||
@@ -27,17 +27,20 @@ class HomeController extends Controller
|
||||
|
||||
$featuredResult = $this->artworks->getFeaturedArtworks(null, 39);
|
||||
if ($featuredResult instanceof \Illuminate\Pagination\LengthAwarePaginator) {
|
||||
$featured = $featuredResult->getCollection()->first();
|
||||
$featuredCollection = $featuredResult->getCollection();
|
||||
$featured = $featuredCollection->get(0);
|
||||
$memberFeatured = $featuredCollection->get(1);
|
||||
} elseif (is_array($featuredResult)) {
|
||||
$featured = $featuredResult[0] ?? null;
|
||||
$memberFeatured = $featuredResult[1] ?? null;
|
||||
} elseif ($featuredResult instanceof Collection) {
|
||||
$featured = $featuredResult->first();
|
||||
$featured = $featuredResult->get(0);
|
||||
$memberFeatured = $featuredResult->get(1);
|
||||
} else {
|
||||
$featured = $featuredResult;
|
||||
$memberFeatured = null;
|
||||
}
|
||||
|
||||
$memberFeatured = $featured;
|
||||
|
||||
$latestUploads = $this->artworks->getLatestArtworks(20);
|
||||
|
||||
// Forum news (prefer migrated legacy news category id 2876, fallback to slug)
|
||||
|
||||
49
app/Http/Controllers/Web/SearchController.php
Normal file
49
app/Http/Controllers/Web/SearchController.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
final class SearchController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ArtworkSearchService $search) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
$sort = $request->query('sort', 'latest');
|
||||
|
||||
$sortMap = [
|
||||
'popular' => 'views:desc',
|
||||
'likes' => 'likes:desc',
|
||||
'latest' => 'created_at:desc',
|
||||
'downloads' => 'downloads:desc',
|
||||
];
|
||||
|
||||
$artworks = null;
|
||||
$popular = collect();
|
||||
|
||||
if ($q !== '') {
|
||||
$artworks = $this->search->search($q, [
|
||||
'sort' => ($sortMap[$sort] ?? 'created_at:desc'),
|
||||
]);
|
||||
} else {
|
||||
$popular = $this->search->popular(16)->getCollection();
|
||||
}
|
||||
|
||||
return view('search.index', [
|
||||
'q' => $q,
|
||||
'sort' => $sort,
|
||||
'artworks' => $artworks ?? collect()->paginate(0),
|
||||
'popular' => $popular,
|
||||
'page_title' => $q !== '' ? 'Search: ' . $q . ' — Skinbase' : 'Search — Skinbase',
|
||||
'page_meta_description' => 'Search Skinbase for artworks, photography, wallpapers and skins.',
|
||||
'page_robots' => 'noindex,follow',
|
||||
]);
|
||||
}
|
||||
}
|
||||
47
app/Http/Controllers/Web/SectionsController.php
Normal file
47
app/Http/Controllers/Web/SectionsController.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ContentType;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SectionsController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// Load all content types with full category tree (roots + children)
|
||||
$contentTypes = ContentType::with([
|
||||
'rootCategories' => function ($q) {
|
||||
$q->active()
|
||||
->withCount(['artworks as artwork_count'])
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name');
|
||||
},
|
||||
'rootCategories.children' => function ($q) {
|
||||
$q->active()
|
||||
->withCount(['artworks as artwork_count'])
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name');
|
||||
},
|
||||
])->orderBy('id')->get();
|
||||
|
||||
// Total artwork counts per content type via a single aggregation query
|
||||
$artworkCountsByType = DB::table('artworks')
|
||||
->join('artwork_category', 'artworks.id', '=', 'artwork_category.artwork_id')
|
||||
->join('categories', 'artwork_category.category_id', '=', 'categories.id')
|
||||
->where('artworks.is_approved', true)
|
||||
->where('artworks.is_public', true)
|
||||
->whereNull('artworks.deleted_at')
|
||||
->select('categories.content_type_id', DB::raw('COUNT(DISTINCT artworks.id) as total'))
|
||||
->groupBy('categories.content_type_id')
|
||||
->pluck('total', 'content_type_id');
|
||||
|
||||
return view('web.sections', [
|
||||
'contentTypes' => $contentTypes,
|
||||
'artworkCountsByType' => $artworkCountsByType,
|
||||
'page_title' => 'Browse Sections',
|
||||
'page_meta_description' => 'Browse all artwork sections on Skinbase — Photography, Wallpapers, Skins and more.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,24 +6,57 @@ namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tag;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
final class TagController extends Controller
|
||||
{
|
||||
public function show(Tag $tag): View
|
||||
public function __construct(private readonly ArtworkSearchService $search) {}
|
||||
|
||||
public function show(Tag $tag, Request $request): View
|
||||
{
|
||||
$artworks = $tag->artworks()
|
||||
->public()
|
||||
->published()
|
||||
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->orderByDesc('artwork_stats.views')
|
||||
->orderByDesc('artworks.published_at')
|
||||
->select('artworks.*')
|
||||
->paginate(24);
|
||||
$sort = $request->query('sort', 'popular'); // popular | latest | downloads
|
||||
$perPage = min((int) $request->query('per_page', 24), 100);
|
||||
|
||||
// Convert sort param to Meili sort expression
|
||||
$sortMap = [
|
||||
'popular' => 'views:desc',
|
||||
'likes' => 'likes:desc',
|
||||
'latest' => 'created_at:desc',
|
||||
'downloads' => 'downloads:desc',
|
||||
];
|
||||
$meiliSort = $sortMap[$sort] ?? 'views:desc';
|
||||
|
||||
$artworks = \App\Models\Artwork::search('')
|
||||
->options([
|
||||
'filter' => 'is_public = true AND is_approved = true AND tags = "' . addslashes($tag->slug) . '"',
|
||||
'sort' => [$meiliSort],
|
||||
])
|
||||
->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']);
|
||||
|
||||
// OG image: first result's thumbnail
|
||||
$ogImage = null;
|
||||
if ($artworks->count() > 0) {
|
||||
$first = $artworks->getCollection()->first();
|
||||
$ogImage = $first?->thumbUrl('md');
|
||||
}
|
||||
|
||||
return view('tags.show', [
|
||||
'tag' => $tag,
|
||||
'tag' => $tag,
|
||||
'artworks' => $artworks,
|
||||
'sort' => $sort,
|
||||
'ogImage' => $ogImage,
|
||||
'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase',
|
||||
'page_meta_description' => 'Browse all Skinbase artworks tagged "' . $tag->name . '". Discover photography, wallpapers and skins.',
|
||||
'page_canonical' => route('tags.show', $tag->slug),
|
||||
'page_robots' => 'index,follow',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,17 +5,12 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\Tags;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final class PopularTagsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
if (! $this->user()) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
return true;
|
||||
return true; // public endpoint
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
|
||||
@@ -5,17 +5,12 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\Tags;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final class TagSearchRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
if (! $this->user()) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
return true;
|
||||
return true; // public endpoint
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
|
||||
42
app/Jobs/DeleteArtworkFromIndexJob.php
Normal file
42
app/Jobs/DeleteArtworkFromIndexJob.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Artwork;
|
||||
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\Log;
|
||||
|
||||
/**
|
||||
* Queued job: remove a single artwork document from Meilisearch.
|
||||
*/
|
||||
class DeleteArtworkFromIndexJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $timeout = 30;
|
||||
|
||||
public function __construct(public readonly int $artworkId) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
// Create a bare model instance just to call unsearchable() with the right ID.
|
||||
$artwork = new Artwork();
|
||||
$artwork->id = $this->artworkId;
|
||||
$artwork->unsearchable();
|
||||
}
|
||||
|
||||
public function failed(\Throwable $e): void
|
||||
{
|
||||
Log::error('DeleteArtworkFromIndexJob failed', [
|
||||
'artwork_id' => $this->artworkId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
52
app/Jobs/IndexArtworkJob.php
Normal file
52
app/Jobs/IndexArtworkJob.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Artwork;
|
||||
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\Log;
|
||||
|
||||
/**
|
||||
* Queued job: index (or re-index) a single Artwork in Meilisearch.
|
||||
*/
|
||||
class IndexArtworkJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $timeout = 30;
|
||||
|
||||
public function __construct(public readonly int $artworkId) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$artwork = Artwork::with(['user', 'tags', 'categories', 'stats', 'awardStat'])
|
||||
->find($this->artworkId);
|
||||
|
||||
if (! $artwork) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $artwork->is_public || ! $artwork->is_approved || ! $artwork->published_at) {
|
||||
// Not public/approved — ensure it is removed from the index.
|
||||
$artwork->unsearchable();
|
||||
return;
|
||||
}
|
||||
|
||||
$artwork->searchable();
|
||||
}
|
||||
|
||||
public function failed(\Throwable $e): void
|
||||
{
|
||||
Log::error('IndexArtworkJob failed', [
|
||||
'artwork_id' => $this->artworkId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use App\Services\ThumbnailService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
/**
|
||||
* App\Models\Artwork
|
||||
@@ -23,7 +24,7 @@ use Illuminate\Support\Facades\DB;
|
||||
*/
|
||||
class Artwork extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use HasFactory, SoftDeletes, Searchable;
|
||||
|
||||
protected $table = 'artworks';
|
||||
|
||||
@@ -173,6 +174,77 @@ class Artwork extends Model
|
||||
return $this->hasMany(ArtworkFeature::class, 'artwork_id');
|
||||
}
|
||||
|
||||
public function awards(): HasMany
|
||||
{
|
||||
return $this->hasMany(ArtworkAward::class);
|
||||
}
|
||||
|
||||
public function awardStat(): HasOne
|
||||
{
|
||||
return $this->hasOne(ArtworkAwardStat::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Meilisearch document for this artwork.
|
||||
* Includes all fields required for search, filtering, sorting, and display.
|
||||
*/
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
$this->loadMissing(['user', 'tags', 'categories.contentType', 'stats', 'awardStat']);
|
||||
|
||||
$stat = $this->stats;
|
||||
$awardStat = $this->awardStat;
|
||||
|
||||
// Orientation derived from pixel dimensions
|
||||
$orientation = 'square';
|
||||
if ($this->width && $this->height) {
|
||||
if ($this->width > $this->height) {
|
||||
$orientation = 'landscape';
|
||||
} elseif ($this->height > $this->width) {
|
||||
$orientation = 'portrait';
|
||||
}
|
||||
}
|
||||
|
||||
// Resolution string e.g. "1920x1080"
|
||||
$resolution = ($this->width && $this->height)
|
||||
? $this->width . 'x' . $this->height
|
||||
: '';
|
||||
|
||||
// Primary category slug (first attached category)
|
||||
$primaryCategory = $this->categories->first();
|
||||
$category = $primaryCategory?->slug ?? '';
|
||||
$content_type = $primaryCategory?->contentType?->slug ?? '';
|
||||
|
||||
// Tag slugs array
|
||||
$tags = $this->tags->pluck('slug')->values()->all();
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'slug' => $this->slug,
|
||||
'title' => $this->title,
|
||||
'description' => (string) ($this->description ?? ''),
|
||||
'author_id' => $this->user_id,
|
||||
'author_name' => $this->user?->name ?? 'Skinbase',
|
||||
'category' => $category,
|
||||
'content_type' => $content_type,
|
||||
'tags' => $tags,
|
||||
'resolution' => $resolution,
|
||||
'orientation' => $orientation,
|
||||
'downloads' => (int) ($stat?->downloads ?? 0),
|
||||
'likes' => (int) ($stat?->favorites ?? 0),
|
||||
'views' => (int) ($stat?->views ?? 0),
|
||||
'created_at' => $this->published_at?->toDateString() ?? $this->created_at?->toDateString() ?? '',
|
||||
'is_public' => (bool) $this->is_public,
|
||||
'is_approved' => (bool) $this->is_approved,
|
||||
'awards' => [
|
||||
'gold' => $awardStat?->gold_count ?? 0,
|
||||
'silver' => $awardStat?->silver_count ?? 0,
|
||||
'bronze' => $awardStat?->bronze_count ?? 0,
|
||||
'score' => $awardStat?->score_total ?? 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Scopes
|
||||
public function scopePublic(Builder $query): Builder
|
||||
{
|
||||
|
||||
44
app/Models/ArtworkAward.php
Normal file
44
app/Models/ArtworkAward.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ArtworkAward extends Model
|
||||
{
|
||||
protected $table = 'artwork_awards';
|
||||
|
||||
protected $fillable = [
|
||||
'artwork_id',
|
||||
'user_id',
|
||||
'medal',
|
||||
'weight',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'artwork_id' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'weight' => 'integer',
|
||||
];
|
||||
|
||||
public const MEDALS = ['gold', 'silver', 'bronze'];
|
||||
|
||||
public const WEIGHTS = [
|
||||
'gold' => 3,
|
||||
'silver' => 2,
|
||||
'bronze' => 1,
|
||||
];
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
40
app/Models/ArtworkAwardStat.php
Normal file
40
app/Models/ArtworkAwardStat.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ArtworkAwardStat extends Model
|
||||
{
|
||||
protected $table = 'artwork_award_stats';
|
||||
|
||||
public $primaryKey = 'artwork_id';
|
||||
public $incrementing = false;
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'artwork_id',
|
||||
'gold_count',
|
||||
'silver_count',
|
||||
'bronze_count',
|
||||
'score_total',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'artwork_id' => 'integer',
|
||||
'gold_count' => 'integer',
|
||||
'silver_count' => 'integer',
|
||||
'bronze_count' => 'integer',
|
||||
'score_total' => 'integer',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class ArtworkComment extends Model
|
||||
protected $table = 'artwork_comments';
|
||||
|
||||
protected $fillable = [
|
||||
'legacy_id',
|
||||
'artwork_id',
|
||||
'user_id',
|
||||
'content',
|
||||
|
||||
47
app/Models/ProfileComment.php
Normal file
47
app/Models/ProfileComment.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProfileComment extends Model
|
||||
{
|
||||
protected $table = 'profile_comments';
|
||||
|
||||
protected $fillable = [
|
||||
'profile_user_id',
|
||||
'author_user_id',
|
||||
'body',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
/** Profile owner */
|
||||
public function profileUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'profile_user_id');
|
||||
}
|
||||
|
||||
/** Comment author */
|
||||
public function author(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'author_user_id');
|
||||
}
|
||||
|
||||
public function authorProfile(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(UserProfile::class, 'author_user_id', 'user_id');
|
||||
}
|
||||
|
||||
/** Scope: only active (not removed) comments */
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
final class Tag extends Model
|
||||
{
|
||||
@@ -32,6 +33,11 @@ final class Tag extends Model
|
||||
->withPivot(['source', 'confidence']);
|
||||
}
|
||||
|
||||
public function synonyms(): HasMany
|
||||
{
|
||||
return $this->hasMany(TagSynonym::class);
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'slug';
|
||||
|
||||
25
app/Models/TagSynonym.php
Normal file
25
app/Models/TagSynonym.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class TagSynonym extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'tag_synonyms';
|
||||
|
||||
protected $fillable = [
|
||||
'tag_id',
|
||||
'synonym',
|
||||
];
|
||||
|
||||
public function tag(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tag::class);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
@@ -73,6 +74,38 @@ class User extends Authenticatable
|
||||
return $this->hasOne(UserProfile::class, 'user_id');
|
||||
}
|
||||
|
||||
public function statistics(): HasOne
|
||||
{
|
||||
return $this->hasOne(UserStatistic::class, 'user_id');
|
||||
}
|
||||
|
||||
/** Users that follow this user */
|
||||
public function followers(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
User::class,
|
||||
'user_followers',
|
||||
'user_id',
|
||||
'follower_id'
|
||||
)->withPivot('created_at');
|
||||
}
|
||||
|
||||
/** Users that this user follows */
|
||||
public function following(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
User::class,
|
||||
'user_followers',
|
||||
'follower_id',
|
||||
'user_id'
|
||||
)->withPivot('created_at');
|
||||
}
|
||||
|
||||
public function profileComments(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProfileComment::class, 'profile_user_id');
|
||||
}
|
||||
|
||||
public function hasRole(string $role): bool
|
||||
{
|
||||
return strtolower((string) ($this->role ?? '')) === strtolower($role);
|
||||
|
||||
37
app/Models/UserFollower.php
Normal file
37
app/Models/UserFollower.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class UserFollower extends Model
|
||||
{
|
||||
protected $table = 'user_followers';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'follower_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
const CREATED_AT = 'created_at';
|
||||
const UPDATED_AT = null;
|
||||
|
||||
/** The user being followed */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/** The user who is following */
|
||||
public function follower(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'follower_id');
|
||||
}
|
||||
}
|
||||
30
app/Models/UserStatistic.php
Normal file
30
app/Models/UserStatistic.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class UserStatistic extends Model
|
||||
{
|
||||
protected $table = 'user_statistics';
|
||||
protected $primaryKey = 'user_id';
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'int';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'uploads',
|
||||
'downloads',
|
||||
'pageviews',
|
||||
'awards',
|
||||
'profile_views',
|
||||
];
|
||||
|
||||
public $timestamps = true;
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
40
app/Observers/ArtworkAwardObserver.php
Normal file
40
app/Observers/ArtworkAwardObserver.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\ArtworkAward;
|
||||
use App\Services\ArtworkAwardService;
|
||||
|
||||
class ArtworkAwardObserver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkAwardService $service
|
||||
) {}
|
||||
|
||||
public function created(ArtworkAward $award): void
|
||||
{
|
||||
$this->refresh($award);
|
||||
}
|
||||
|
||||
public function updated(ArtworkAward $award): void
|
||||
{
|
||||
$this->refresh($award);
|
||||
}
|
||||
|
||||
public function deleted(ArtworkAward $award): void
|
||||
{
|
||||
$this->refresh($award);
|
||||
}
|
||||
|
||||
private function refresh(ArtworkAward $award): void
|
||||
{
|
||||
$this->service->recalcStats($award->artwork_id);
|
||||
|
||||
$artwork = $award->artwork;
|
||||
if ($artwork) {
|
||||
$this->service->syncToSearch($artwork);
|
||||
}
|
||||
}
|
||||
}
|
||||
56
app/Observers/ArtworkObserver.php
Normal file
56
app/Observers/ArtworkObserver.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkSearchIndexer;
|
||||
|
||||
/**
|
||||
* Syncs artwork documents to Meilisearch on every relevant model event.
|
||||
*
|
||||
* All operations are dispatched to the queue — no blocking calls.
|
||||
*/
|
||||
class ArtworkObserver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkSearchIndexer $indexer
|
||||
) {}
|
||||
|
||||
/** New artwork created — index once published and approved. */
|
||||
public function created(Artwork $artwork): void
|
||||
{
|
||||
$this->indexer->index($artwork);
|
||||
}
|
||||
|
||||
/** Artwork updated — covers publish, approval, metadata changes. */
|
||||
public function updated(Artwork $artwork): void
|
||||
{
|
||||
// When soft-deleted, remove from index immediately.
|
||||
if ($artwork->isDirty('deleted_at') && $artwork->deleted_at !== null) {
|
||||
$this->indexer->delete($artwork->id);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->indexer->update($artwork);
|
||||
}
|
||||
|
||||
/** Soft delete — remove from search. */
|
||||
public function deleted(Artwork $artwork): void
|
||||
{
|
||||
$this->indexer->delete($artwork->id);
|
||||
}
|
||||
|
||||
/** Force delete — ensure removal from index. */
|
||||
public function forceDeleted(Artwork $artwork): void
|
||||
{
|
||||
$this->indexer->delete($artwork->id);
|
||||
}
|
||||
|
||||
/** Restored from soft-delete — re-index. */
|
||||
public function restored(Artwork $artwork): void
|
||||
{
|
||||
$this->indexer->index($artwork);
|
||||
}
|
||||
}
|
||||
69
app/Policies/ArtworkAwardPolicy.php
Normal file
69
app/Policies/ArtworkAwardPolicy.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\ArtworkAward;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
|
||||
class ArtworkAwardPolicy
|
||||
{
|
||||
/**
|
||||
* Admins bypass all checks.
|
||||
*/
|
||||
public function before(User $user, string $ability): ?bool
|
||||
{
|
||||
if (method_exists($user, 'isAdmin') && $user->isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Any authenticated user with a mature account may award any artwork
|
||||
* that isn't their own.
|
||||
* Returns false (→ 403 or 404 based on caller) when the check fails.
|
||||
*/
|
||||
public function award(User $user, Artwork $artwork): bool
|
||||
{
|
||||
if (! $artwork->is_public || ! $artwork->is_approved) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($artwork->user_id === $user->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->accountIsMature($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user may change a medal they already placed.
|
||||
*/
|
||||
public function change(User $user, ArtworkAward $award): bool
|
||||
{
|
||||
return $user->id === $award->user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user may remove a medal they already placed.
|
||||
*/
|
||||
public function remove(User $user, ArtworkAward $award): bool
|
||||
{
|
||||
return $user->id === $award->user_id;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function accountIsMature(User $user): bool
|
||||
{
|
||||
if (! $user->created_at) {
|
||||
return true; // cannot verify — allow
|
||||
}
|
||||
|
||||
return $user->created_at->diffInDays(now()) >= 7;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\Models\ArtworkAward;
|
||||
use App\Observers\ArtworkAwardObserver;
|
||||
use App\Models\Artwork;
|
||||
use App\Observers\ArtworkObserver;
|
||||
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
|
||||
use App\Services\Upload\UploadDraftService;
|
||||
use Illuminate\Support\Facades\View;
|
||||
@@ -37,6 +41,9 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->configureUploadRateLimiters();
|
||||
$this->configureMailFailureLogging();
|
||||
|
||||
ArtworkAward::observe(ArtworkAwardObserver::class);
|
||||
Artwork::observe(ArtworkObserver::class);
|
||||
|
||||
// Provide toolbar counts and user info to layout views (port of legacy toolbar logic)
|
||||
View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) {
|
||||
$uploadCount = $favCount = $msgCount = $noticeCount = 0;
|
||||
|
||||
@@ -4,7 +4,9 @@ namespace App\Providers;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkAward;
|
||||
use App\Policies\ArtworkPolicy;
|
||||
use App\Policies\ArtworkAwardPolicy;
|
||||
|
||||
class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -14,7 +16,8 @@ class AuthServiceProvider extends ServiceProvider
|
||||
* @var array<class-string, class-string>
|
||||
*/
|
||||
protected $policies = [
|
||||
Artwork::class => ArtworkPolicy::class,
|
||||
Artwork::class => ArtworkPolicy::class,
|
||||
ArtworkAward::class => ArtworkAwardPolicy::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
132
app/Services/ArtworkAwardService.php
Normal file
132
app/Services/ArtworkAwardService.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkAward;
|
||||
use App\Models\ArtworkAwardStat;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ArtworkAwardService
|
||||
{
|
||||
/**
|
||||
* Award an artwork with the given medal.
|
||||
* Throws ValidationException if the user already awarded this artwork.
|
||||
*/
|
||||
public function award(Artwork $artwork, User $user, string $medal): ArtworkAward
|
||||
{
|
||||
$this->validateMedal($medal);
|
||||
|
||||
$existing = ArtworkAward::where('artwork_id', $artwork->id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
throw ValidationException::withMessages([
|
||||
'medal' => 'You have already awarded this artwork. Use change to update.',
|
||||
]);
|
||||
}
|
||||
|
||||
$award = ArtworkAward::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $user->id,
|
||||
'medal' => $medal,
|
||||
'weight' => ArtworkAward::WEIGHTS[$medal],
|
||||
]);
|
||||
|
||||
$this->recalcStats($artwork->id);
|
||||
$this->syncToSearch($artwork);
|
||||
|
||||
return $award;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change an existing award medal for a user/artwork pair.
|
||||
*/
|
||||
public function changeAward(Artwork $artwork, User $user, string $medal): ArtworkAward
|
||||
{
|
||||
$this->validateMedal($medal);
|
||||
|
||||
$award = ArtworkAward::where('artwork_id', $artwork->id)
|
||||
->where('user_id', $user->id)
|
||||
->firstOrFail();
|
||||
|
||||
$award->update([
|
||||
'medal' => $medal,
|
||||
'weight' => ArtworkAward::WEIGHTS[$medal],
|
||||
]);
|
||||
|
||||
$this->recalcStats($artwork->id);
|
||||
$this->syncToSearch($artwork);
|
||||
|
||||
return $award->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an award for a user/artwork pair.
|
||||
*/
|
||||
public function removeAward(Artwork $artwork, User $user): void
|
||||
{
|
||||
ArtworkAward::where('artwork_id', $artwork->id)
|
||||
->where('user_id', $user->id)
|
||||
->delete();
|
||||
|
||||
$this->recalcStats($artwork->id);
|
||||
$this->syncToSearch($artwork);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate and persist stats for the given artwork.
|
||||
*/
|
||||
public function recalcStats(int $artworkId): ArtworkAwardStat
|
||||
{
|
||||
$counts = DB::table('artwork_awards')
|
||||
->where('artwork_id', $artworkId)
|
||||
->selectRaw('
|
||||
SUM(medal = \'gold\') AS gold_count,
|
||||
SUM(medal = \'silver\') AS silver_count,
|
||||
SUM(medal = \'bronze\') AS bronze_count
|
||||
')
|
||||
->first();
|
||||
|
||||
$gold = (int) ($counts->gold_count ?? 0);
|
||||
$silver = (int) ($counts->silver_count ?? 0);
|
||||
$bronze = (int) ($counts->bronze_count ?? 0);
|
||||
$score = ($gold * 3) + ($silver * 2) + ($bronze * 1);
|
||||
|
||||
$stat = ArtworkAwardStat::updateOrCreate(
|
||||
['artwork_id' => $artworkId],
|
||||
[
|
||||
'gold_count' => $gold,
|
||||
'silver_count' => $silver,
|
||||
'bronze_count' => $bronze,
|
||||
'score_total' => $score,
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
return $stat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a non-blocking reindex for the artwork after award stats change.
|
||||
*/
|
||||
public function syncToSearch(Artwork $artwork): void
|
||||
{
|
||||
IndexArtworkJob::dispatch($artwork->id);
|
||||
}
|
||||
|
||||
private function validateMedal(string $medal): void
|
||||
{
|
||||
if (! in_array($medal, ArtworkAward::MEDALS, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'medal' => 'Invalid medal. Must be gold, silver, or bronze.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
61
app/Services/ArtworkSearchIndexer.php
Normal file
61
app/Services/ArtworkSearchIndexer.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\DeleteArtworkFromIndexJob;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Manages Meilisearch index operations for artworks.
|
||||
*
|
||||
* All write operations are dispatched to queues — never block requests.
|
||||
*/
|
||||
final class ArtworkSearchIndexer
|
||||
{
|
||||
/**
|
||||
* Queue an artwork for indexing (insert or update).
|
||||
*/
|
||||
public function index(Artwork $artwork): void
|
||||
{
|
||||
IndexArtworkJob::dispatch($artwork->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue an artwork for re-indexing after an update.
|
||||
*/
|
||||
public function update(Artwork $artwork): void
|
||||
{
|
||||
IndexArtworkJob::dispatch($artwork->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue removal of an artwork from the index.
|
||||
*/
|
||||
public function delete(int $id): void
|
||||
{
|
||||
DeleteArtworkFromIndexJob::dispatch($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the entire artworks index in background chunks.
|
||||
* Run via: php artisan artworks:search-rebuild
|
||||
*/
|
||||
public function rebuildAll(int $chunkSize = 500): void
|
||||
{
|
||||
Artwork::with(['user', 'tags', 'categories', 'stats', 'awardStat'])
|
||||
->public()
|
||||
->published()
|
||||
->orderBy('id')
|
||||
->chunk($chunkSize, function ($artworks): void {
|
||||
foreach ($artworks as $artwork) {
|
||||
IndexArtworkJob::dispatch($artwork->id);
|
||||
}
|
||||
});
|
||||
|
||||
Log::info('ArtworkSearchIndexer::rebuildAll — jobs dispatched');
|
||||
}
|
||||
}
|
||||
191
app/Services/ArtworkSearchService.php
Normal file
191
app/Services/ArtworkSearchService.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* High-level search API powered by Meilisearch via Laravel Scout.
|
||||
*
|
||||
* No Meili calls in controllers — always go through this service.
|
||||
*/
|
||||
final class ArtworkSearchService
|
||||
{
|
||||
private const BASE_FILTER = 'is_public = true AND is_approved = true';
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* Full-text search with optional filters.
|
||||
*
|
||||
* Supported $filters keys:
|
||||
* tags array<string> — tag slugs (AND match)
|
||||
* category string
|
||||
* orientation string — landscape | portrait | square
|
||||
* resolution string — e.g. "1920x1080"
|
||||
* author_id int
|
||||
* sort string — created_at|downloads|likes|views (suffix :asc or :desc)
|
||||
*/
|
||||
public function search(string $q, array $filters = [], int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$filterParts = [self::BASE_FILTER];
|
||||
$sort = [];
|
||||
|
||||
if (! empty($filters['tags'])) {
|
||||
foreach ((array) $filters['tags'] as $tag) {
|
||||
$filterParts[] = 'tags = "' . addslashes((string) $tag) . '"';
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($filters['category'])) {
|
||||
$filterParts[] = 'category = "' . addslashes((string) $filters['category']) . '"';
|
||||
}
|
||||
|
||||
if (! empty($filters['orientation'])) {
|
||||
$filterParts[] = 'orientation = "' . addslashes((string) $filters['orientation']) . '"';
|
||||
}
|
||||
|
||||
if (! empty($filters['resolution'])) {
|
||||
$filterParts[] = 'resolution = "' . addslashes((string) $filters['resolution']) . '"';
|
||||
}
|
||||
|
||||
if (! empty($filters['author_id'])) {
|
||||
$filterParts[] = 'author_id = ' . (int) $filters['author_id'];
|
||||
}
|
||||
|
||||
if (! empty($filters['sort'])) {
|
||||
[$field, $dir] = $this->parseSort((string) $filters['sort']);
|
||||
if ($field) {
|
||||
$sort[] = $field . ':' . $dir;
|
||||
}
|
||||
}
|
||||
|
||||
$options = ['filter' => implode(' AND ', $filterParts)];
|
||||
if ($sort !== []) {
|
||||
$options['sort'] = $sort;
|
||||
}
|
||||
|
||||
return Artwork::search($q ?: '')
|
||||
->options($options)
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load artworks for a tag page, sorted by views + likes descending.
|
||||
*/
|
||||
public function byTag(string $slug, int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$tag = Tag::where('slug', $slug)->first();
|
||||
if (! $tag) {
|
||||
return $this->emptyPaginator($perPage);
|
||||
}
|
||||
|
||||
$cacheKey = "search.tag.{$slug}.page." . request()->get('page', 1);
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($slug, $perPage) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND tags = "' . addslashes($slug) . '"',
|
||||
'sort' => ['views:desc', 'likes:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load artworks for a category, sorted by created_at desc.
|
||||
*/
|
||||
public function byCategory(string $cat, int $perPage = 24, array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$cacheKey = "search.cat.{$cat}.page." . request()->get('page', 1);
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($cat, $perPage) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"',
|
||||
'sort' => ['created_at:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Related artworks: same tags, different artwork, ranked by views + likes.
|
||||
* Limit 12.
|
||||
*/
|
||||
public function related(Artwork $artwork, int $limit = 12): LengthAwarePaginator
|
||||
{
|
||||
$tags = $artwork->tags()->pluck('tags.slug')->values()->all();
|
||||
|
||||
if ($tags === []) {
|
||||
return $this->popular($limit);
|
||||
}
|
||||
|
||||
$cacheKey = "search.related.{$artwork->id}";
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork, $tags, $limit) {
|
||||
$tagFilters = implode(' OR ', array_map(
|
||||
fn ($t) => 'tags = "' . addslashes($t) . '"',
|
||||
$tags
|
||||
));
|
||||
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND id != ' . $artwork->id . ' AND (' . $tagFilters . ')',
|
||||
'sort' => ['views:desc', 'likes:desc'],
|
||||
])
|
||||
->paginate($limit);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Most popular artworks by views.
|
||||
*/
|
||||
public function popular(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
return Cache::remember('search.popular.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['views:desc', 'likes:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Most recent artworks by created_at.
|
||||
*/
|
||||
public function recent(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
return Cache::remember('search.recent.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['created_at:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function parseSort(string $sort): array
|
||||
{
|
||||
$allowed = ['created_at', 'downloads', 'likes', 'views'];
|
||||
$parts = explode(':', $sort, 2);
|
||||
$field = $parts[0] ?? '';
|
||||
$dir = strtolower($parts[1] ?? 'desc') === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
return in_array($field, $allowed, true) ? [$field, $dir] : [null, 'desc'];
|
||||
}
|
||||
|
||||
private function emptyPaginator(int $perPage): LengthAwarePaginator
|
||||
{
|
||||
return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage);
|
||||
}
|
||||
}
|
||||
@@ -301,7 +301,7 @@ class ArtworkService
|
||||
{
|
||||
$query = Artwork::where('user_id', $userId)
|
||||
->with([
|
||||
'user:id,name',
|
||||
'user:id,name,username',
|
||||
'categories' => function ($q) {
|
||||
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
||||
|
||||
@@ -6,6 +6,17 @@ namespace App\Services;
|
||||
|
||||
final class TagNormalizer
|
||||
{
|
||||
/**
|
||||
* Normalize a raw tag string to a clean, ASCII-only slug.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Trim + lowercase
|
||||
* 2. Transliterate Unicode → ASCII (iconv or Transliterator)
|
||||
* 3. Strip everything except [a-z0-9 -]
|
||||
* 4. Collapse whitespace, replace spaces with hyphens
|
||||
* 5. Strip leading/trailing hyphens
|
||||
* 6. Enforce max length
|
||||
*/
|
||||
public function normalize(string $tag): string
|
||||
{
|
||||
$value = trim($tag);
|
||||
@@ -15,25 +26,63 @@ final class TagNormalizer
|
||||
|
||||
$value = mb_strtolower($value, 'UTF-8');
|
||||
|
||||
// Remove emoji / symbols and keep only letters, numbers, whitespace and hyphens.
|
||||
// (Unicode safe: \p{L} letters, \p{N} numbers)
|
||||
$value = (string) preg_replace('/[^\p{L}\p{N}\s\-]+/u', '', $value);
|
||||
// Transliterate to ASCII (e.g. é→e, ü→u, 日→nihon).
|
||||
// Try Transliterator first (intl extension), fall back to iconv.
|
||||
if (class_exists('\Transliterator')) {
|
||||
$trans = \Transliterator::create('Any-Latin; Latin-ASCII; Lower()');
|
||||
if ($trans !== null) {
|
||||
$value = (string) ($trans->transliterate($value) ?: $value);
|
||||
}
|
||||
} elseif (function_exists('iconv')) {
|
||||
$ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
|
||||
if ($ascii !== false && $ascii !== '') {
|
||||
$value = $ascii;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep only ASCII letters, digits, spaces and hyphens.
|
||||
$value = (string) preg_replace('/[^a-z0-9\s\-]+/', '', $value);
|
||||
|
||||
// Normalize whitespace.
|
||||
$value = (string) preg_replace('/\s+/u', ' ', $value);
|
||||
$value = (string) preg_replace('/\s+/', ' ', $value);
|
||||
$value = trim($value);
|
||||
|
||||
// Spaces -> hyphens and collapse repeats.
|
||||
// Spaces → hyphens, collapse repeats, strip edge hyphens.
|
||||
$value = str_replace(' ', '-', $value);
|
||||
$value = (string) preg_replace('/\-+/u', '-', $value);
|
||||
$value = trim($value, "-\t\n\r\0\x0B");
|
||||
$value = (string) preg_replace('/-+/', '-', $value);
|
||||
$value = trim($value, '-');
|
||||
|
||||
$maxLength = (int) config('tags.max_length', 32);
|
||||
if ($maxLength > 0 && mb_strlen($value, 'UTF-8') > $maxLength) {
|
||||
$value = mb_substr($value, 0, $maxLength, 'UTF-8');
|
||||
if ($maxLength > 0 && strlen($value) > $maxLength) {
|
||||
$value = substr($value, 0, $maxLength);
|
||||
$value = rtrim($value, '-');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a normalized slug back to a human-readable display name.
|
||||
*
|
||||
* "blue-sky" → "Blue Sky"
|
||||
* "sci-fi-landscape" → "Sci Fi Landscape"
|
||||
* "3d" → "3D"
|
||||
*
|
||||
* If the raw input is available, pass it instead of the slug — it gives
|
||||
* better casing (e.g. the AI sends "digital painting", no hyphens yet).
|
||||
*/
|
||||
public function toDisplayName(string $slugOrRaw): string
|
||||
{
|
||||
// If raw input still has mixed case or spaces, title-case it directly.
|
||||
$clean = trim($slugOrRaw);
|
||||
if ($clean === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Replace hyphens and underscores with spaces for word splitting.
|
||||
$spaced = str_replace(['-', '_'], ' ', $clean);
|
||||
|
||||
// Title-case each word (mb_convert_case handles UTF-8 safely).
|
||||
return mb_convert_case($spaced, MB_CASE_TITLE, 'UTF-8');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Services\TagNormalizer;
|
||||
@@ -19,14 +20,17 @@ final class TagService
|
||||
|
||||
public function createOrFindTag(string $rawTag): Tag
|
||||
{
|
||||
$normalized = $this->normalizer->normalize($rawTag);
|
||||
$normalized = $this->normalizer->normalize($rawTag);
|
||||
$this->validateNormalizedTag($normalized);
|
||||
|
||||
// Keep tags normalized in both name and slug (spec: normalize all tags).
|
||||
// Unique(slug) + Unique(name) prevents duplicates.
|
||||
// Derive display name from the clean slug, not the raw input.
|
||||
// This ensures consistent casing regardless of how the tag was submitted.
|
||||
// "digital-art" → "Digital Art", "sci-fi-landscape" → "Sci Fi Landscape"
|
||||
$displayName = $this->normalizer->toDisplayName($normalized);
|
||||
|
||||
return Tag::query()->firstOrCreate(
|
||||
['slug' => $normalized],
|
||||
['name' => $normalized, 'usage_count' => 0, 'is_active' => true]
|
||||
['name' => $displayName, 'usage_count' => 0, 'is_active' => true]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,6 +87,8 @@ final class TagService
|
||||
$artwork->tags()->updateExistingPivot($tagId, $payload);
|
||||
}
|
||||
});
|
||||
|
||||
$this->queueReindex($artwork);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,6 +153,8 @@ final class TagService
|
||||
$this->incrementUsageCounts($newlyAttachedTagIds);
|
||||
}
|
||||
});
|
||||
|
||||
$this->queueReindex($artwork);
|
||||
}
|
||||
|
||||
public function detachTags(Artwork $artwork, array $tagSlugsOrIds): void
|
||||
@@ -179,6 +187,8 @@ final class TagService
|
||||
$artwork->tags()->detach($existing);
|
||||
$this->decrementUsageCounts($existing);
|
||||
});
|
||||
|
||||
$this->queueReindex($artwork);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,6 +246,8 @@ final class TagService
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->queueReindex($artwork);
|
||||
}
|
||||
|
||||
public function updateUsageCount(Tag $tag): void
|
||||
@@ -326,4 +338,13 @@ final class TagService
|
||||
->whereIn('id', $tagIds)
|
||||
->update(['usage_count' => DB::raw('CASE WHEN usage_count > 0 THEN usage_count - 1 ELSE 0 END')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a non-blocking reindex job for the given artwork.
|
||||
* Called after every tag mutation so the search index stays consistent.
|
||||
*/
|
||||
private function queueReindex(Artwork $artwork): void
|
||||
{
|
||||
IndexArtworkJob::dispatch($artwork->id);
|
||||
}
|
||||
}
|
||||
|
||||
140
config/scout.php
Normal file
140
config/scout.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Search Engine
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'driver' => env('SCOUT_DRIVER', 'meilisearch'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Index Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'prefix' => env('SCOUT_PREFIX', env('MEILI_PREFIX', '')),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Data Syncing
|
||||
|--------------------------------------------------------------------------
|
||||
| Always queue Scout index operations so they never block HTTP requests.
|
||||
*/
|
||||
|
||||
'queue' => [
|
||||
'connection' => env('SCOUT_QUEUE_CONNECTION', env('QUEUE_CONNECTION', 'redis')),
|
||||
'queue' => env('SCOUT_QUEUE_NAME', 'search'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Transactions
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'after_commit' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Chunk Sizes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'chunk' => [
|
||||
'searchable' => 500,
|
||||
'unsearchable' => 500,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Soft Deletes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'soft_delete' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Identify User
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'identify' => env('SCOUT_IDENTIFY', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Meilisearch
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'meilisearch' => [
|
||||
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
|
||||
'key' => env('MEILISEARCH_KEY'),
|
||||
|
||||
/*
|
||||
| Index-level settings pushed via: php artisan scout:sync-index-settings
|
||||
*/
|
||||
'index-settings' => [
|
||||
/*
|
||||
* Key must match the full Meilisearch index name (prefix + model index).
|
||||
* Prefix is controlled by MEILI_PREFIX / SCOUT_PREFIX env variable.
|
||||
* Default local dev: 'artworks'. Production: 'skinbase_prod_artworks'.
|
||||
*/
|
||||
env('SCOUT_PREFIX', env('MEILI_PREFIX', '')) . 'artworks' => [
|
||||
'searchableAttributes' => [
|
||||
'title',
|
||||
'tags',
|
||||
'author_name',
|
||||
'description',
|
||||
],
|
||||
'filterableAttributes' => [
|
||||
'tags',
|
||||
'category',
|
||||
'content_type',
|
||||
'orientation',
|
||||
'resolution',
|
||||
'author_id',
|
||||
'is_public',
|
||||
'is_approved',
|
||||
],
|
||||
'sortableAttributes' => [
|
||||
'created_at',
|
||||
'downloads',
|
||||
'likes',
|
||||
'views',
|
||||
],
|
||||
'rankingRules' => [
|
||||
'words',
|
||||
'typo',
|
||||
'proximity',
|
||||
'attribute',
|
||||
'sort',
|
||||
'exactness',
|
||||
],
|
||||
'typoTolerance' => [
|
||||
'enabled' => true,
|
||||
'minWordSizeForTypos' => [
|
||||
'oneTypo' => 4,
|
||||
'twoTypos' => 8,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Algolia (unused but required by package)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'algolia' => [
|
||||
'id' => env('ALGOLIA_APP_ID', ''),
|
||||
'secret' => env('ALGOLIA_SECRET', ''),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -31,4 +31,20 @@ return [
|
||||
|
||||
// Which derivative variant to send to vision services.
|
||||
'image_variant' => env('VISION_IMAGE_VARIANT', 'md'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| LM Studio – local multimodal inference (tag generation)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'lm_studio' => [
|
||||
'base_url' => env('LM_STUDIO_URL', 'http://172.28.16.1:8200'),
|
||||
'model' => env('LM_STUDIO_MODEL', 'google/gemma-3-4b'),
|
||||
'timeout' => (int) env('LM_STUDIO_TIMEOUT', 60),
|
||||
'connect_timeout' => (int) env('LM_STUDIO_CONNECT_TIMEOUT', 5),
|
||||
'temperature' => (float) env('LM_STUDIO_TEMPERATURE', 0.3),
|
||||
'max_tokens' => (int) env('LM_STUDIO_MAX_TOKENS', 300),
|
||||
// Maximum number of AI-suggested tags to keep per artwork.
|
||||
'max_tags' => (int) env('LM_STUDIO_MAX_TAGS', 12),
|
||||
],
|
||||
];
|
||||
|
||||
34
database/factories/TagFactory.php
Normal file
34
database/factories/TagFactory.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<Tag>
|
||||
*/
|
||||
final class TagFactory extends Factory
|
||||
{
|
||||
protected $model = Tag::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$name = $this->faker->unique()->words(mt_rand(1, 2), true);
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'slug' => Str::slug($name),
|
||||
'usage_count' => $this->faker->numberBetween(0, 500),
|
||||
'is_active' => true,
|
||||
];
|
||||
}
|
||||
|
||||
public function inactive(): static
|
||||
{
|
||||
return $this->state(['is_active' => false]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('artwork_awards', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('artwork_id');
|
||||
$table->unsignedBigInteger('user_id');
|
||||
$table->enum('medal', ['gold', 'silver', 'bronze']);
|
||||
$table->tinyInteger('weight')->unsigned()->default(1);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['artwork_id', 'user_id']);
|
||||
$table->index('artwork_id');
|
||||
$table->index('user_id');
|
||||
|
||||
$table->foreign('artwork_id')
|
||||
->references('id')->on('artworks')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->foreign('user_id')
|
||||
->references('id')->on('users')
|
||||
->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('artwork_awards');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_followers', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
|
||||
// The user being followed
|
||||
$table->unsignedBigInteger('user_id');
|
||||
// The follower
|
||||
$table->unsignedBigInteger('follower_id');
|
||||
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->unique(['user_id', 'follower_id'], 'uq_user_follower');
|
||||
$table->index('user_id', 'idx_uf_user');
|
||||
$table->index('follower_id', 'idx_uf_follower');
|
||||
|
||||
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
|
||||
$table->foreign('follower_id')->references('id')->on('users')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_followers');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('artwork_award_stats', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('artwork_id')->primary();
|
||||
$table->unsignedInteger('gold_count')->default(0);
|
||||
$table->unsignedInteger('silver_count')->default(0);
|
||||
$table->unsignedInteger('bronze_count')->default(0);
|
||||
$table->unsignedInteger('score_total')->default(0);
|
||||
$table->timestamp('updated_at')->nullable();
|
||||
|
||||
$table->foreign('artwork_id')
|
||||
->references('id')->on('artworks')
|
||||
->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('artwork_award_stats');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('profile_comments', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
|
||||
// Profile owner (who received the comment)
|
||||
$table->unsignedBigInteger('profile_user_id');
|
||||
// Who wrote the comment
|
||||
$table->unsignedBigInteger('author_user_id');
|
||||
|
||||
$table->text('body');
|
||||
$table->boolean('is_active')->default(true);
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('profile_user_id', 'idx_pc_profile');
|
||||
$table->index('author_user_id', 'idx_pc_author');
|
||||
$table->index(['profile_user_id', 'is_active', 'created_at'], 'idx_pc_active_feed');
|
||||
|
||||
$table->foreign('profile_user_id')->references('id')->on('users')->cascadeOnDelete();
|
||||
$table->foreign('author_user_id')->references('id')->on('users')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('profile_comments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('artwork_comments', function (Blueprint $table) {
|
||||
// Tracks the original legacy comment_id for idempotent imports.
|
||||
// NULL for comments created natively in the new system.
|
||||
$table->unsignedInteger('legacy_id')->nullable()->unique()->after('id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('artwork_comments', function (Blueprint $table) {
|
||||
$table->dropUnique(['legacy_id']);
|
||||
$table->dropColumn('legacy_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('user_statistics', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('user_statistics', 'profile_views')) {
|
||||
$table->unsignedInteger('profile_views')->default(0)->after('awards');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('user_statistics', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('user_statistics', 'profile_views')) {
|
||||
$table->dropColumn('profile_views');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
2127
database/old_skinbase_structure.sql
Normal file
2127
database/old_skinbase_structure.sql
Normal file
File diff suppressed because it is too large
Load Diff
210
docs/tags-system.md
Normal file
210
docs/tags-system.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Skinbase Tag System
|
||||
|
||||
Architecture reference for the Skinbase unified tag system.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
TagInput (React)
|
||||
├─ GET /api/tags/search?q= → TagController@search
|
||||
└─ GET /api/tags/popular → TagController@popular
|
||||
|
||||
ArtworkTagController
|
||||
├─ GET /api/artworks/{id}/tags
|
||||
├─ POST /api/artworks/{id}/tags → TagService::attachUserTags()
|
||||
├─ PUT /api/artworks/{id}/tags → TagService::syncTags()
|
||||
└─ DELETE /api/artworks/{id}/tags/{tag} → TagService::detachTags()
|
||||
|
||||
TagService → TagNormalizer → Tag (model) → artwork_tag (pivot)
|
||||
ArtworkObserver / TagService → IndexArtworkJob → Meilisearch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### `tags`
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint PK | |
|
||||
| name | varchar(64) | unique |
|
||||
| slug | varchar(64) | unique, normalized |
|
||||
| usage_count | bigint | maintained by TagService |
|
||||
| is_active | boolean | false = hidden from search |
|
||||
| created_at / updated_at | timestamps | |
|
||||
|
||||
### `artwork_tag` (pivot)
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| artwork_id | bigint FK | |
|
||||
| tag_id | bigint FK | |
|
||||
| source | enum(user,ai,system) | |
|
||||
| confidence | float NULL | AI only |
|
||||
| created_at | timestamp | |
|
||||
|
||||
PK: `(artwork_id, tag_id)` — one row per pair, user source takes precedence over ai.
|
||||
|
||||
### `tag_synonyms`
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | bigint PK | |
|
||||
| tag_id | bigint FK | cascade delete |
|
||||
| synonym | varchar(64) | |
|
||||
|
||||
Unique: `(tag_id, synonym)`.
|
||||
|
||||
---
|
||||
|
||||
## Services
|
||||
|
||||
### `TagNormalizer`
|
||||
|
||||
`App\Services\TagNormalizer`
|
||||
|
||||
```php
|
||||
$n->normalize(' Café Night!! '); // → 'cafe-night'
|
||||
$n->normalize('🚀 Rocket'); // → 'rocket'
|
||||
```
|
||||
|
||||
Rules applied in order:
|
||||
1. Trim + lowercase (UTF-8)
|
||||
2. Unicode → ASCII transliteration (Transliterator / iconv)
|
||||
3. Strip everything except `[a-z0-9 -]`
|
||||
4. Collapse whitespace → hyphens
|
||||
5. Strip leading/trailing hyphens
|
||||
6. Clamp to `config('tags.max_length', 32)` characters
|
||||
|
||||
### `TagService`
|
||||
|
||||
`App\Services\TagService`
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `attachUserTags(Artwork, string[])` | Normalize → findOrCreate → attach with `source=user`. Skips duplicates. Max 15. |
|
||||
| `attachAiTags(Artwork, array{tag,confidence}[])` | Normalize → findOrCreate → syncWithoutDetaching `source=ai`. Existing user pivot is never overwritten. |
|
||||
| `detachTags(Artwork, string[])` | Detach by slug, decrement usage_count. |
|
||||
| `syncTags(Artwork, string[])` | Replace full user-tag set. New tags increment, removed tags decrement. |
|
||||
| `updateUsageCount(Tag, int)` | Clamp-safe increment/decrement. |
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Public (no auth)
|
||||
|
||||
```
|
||||
GET /api/tags/search?q={query}&limit={n}
|
||||
GET /api/tags/popular?limit={n}
|
||||
```
|
||||
|
||||
Response shape:
|
||||
```json
|
||||
{ "data": [{ "id": 1, "name": "city", "slug": "city", "usage_count": 412 }] }
|
||||
```
|
||||
|
||||
### Authenticated (artwork owner or admin)
|
||||
|
||||
```
|
||||
GET /api/artworks/{id}/tags
|
||||
POST /api/artworks/{id}/tags body: { "tags": ["city", "night"] }
|
||||
PUT /api/artworks/{id}/tags body: { "tags": ["city", "night", "rain"] }
|
||||
DELETE /api/artworks/{id}/tags/{tag}
|
||||
```
|
||||
|
||||
All tag mutations dispatch `IndexArtworkJob` to keep Meilisearch in sync.
|
||||
|
||||
---
|
||||
|
||||
## Meilisearch Integration
|
||||
|
||||
Index name: `skinbase_prod_artworks` (prefix from `MEILI_PREFIX` env var).
|
||||
|
||||
Tags are stored in the `tags` field as an array of slugs:
|
||||
```json
|
||||
{ "id": 42, "tags": ["city", "night", "cyberpunk"], ... }
|
||||
```
|
||||
|
||||
Filterable: `tags`
|
||||
Searchable: `tags` (full-text match on tag slugs)
|
||||
|
||||
Sync triggered by:
|
||||
- `ArtworkObserver` (created/updated/deleted/restored)
|
||||
- `TagService` — all mutation methods dispatch `IndexArtworkJob`
|
||||
- `ArtworkAwardService::syncToSearch()`
|
||||
|
||||
Rebuild all: `php artisan artworks:search-rebuild`
|
||||
|
||||
---
|
||||
|
||||
## UI Component
|
||||
|
||||
`resources/js/components/tags/TagInput.jsx`
|
||||
|
||||
```jsx
|
||||
<TagInput
|
||||
value={tags} // string[]
|
||||
onChange={setTags} // (string[]) => void
|
||||
suggestedTags={aiTags} // [{ tag, confidence }]
|
||||
maxTags={15}
|
||||
searchEndpoint="/api/tags/search"
|
||||
popularEndpoint="/api/tags/popular"
|
||||
/>
|
||||
```
|
||||
|
||||
**Keyboard shortcuts:**
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| Enter / Comma | Add current input as tag |
|
||||
| Tab | Accept highlighted suggestion or add input |
|
||||
| Backspace (empty input) | Remove last tag |
|
||||
| Arrow Up/Down | Navigate suggestions |
|
||||
| Escape | Close suggestions |
|
||||
|
||||
Paste splits on commas automatically.
|
||||
|
||||
---
|
||||
|
||||
## Tag Pages (SEO)
|
||||
|
||||
Route: `GET /tag/{slug}`
|
||||
Controller: `TagController@show` (`App\Http\Controllers\Web\TagController`)
|
||||
|
||||
SEO output per page:
|
||||
- `<title>` → `{Tag} Artworks | Skinbase`
|
||||
- `<meta name="description">` → `Browse {count}+ artworks tagged with {tag}.`
|
||||
- `<link rel="canonical">` → `https://skinbase.org/tag/{slug}`
|
||||
- JSON-LD `CollectionPage` schema
|
||||
- Prev/next pagination links
|
||||
- `?sort=popular|latest|liked|downloads` supported
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
`config/tags.php`
|
||||
|
||||
```php
|
||||
'max_length' => 32, // max chars per tag slug
|
||||
'max_per_upload'=> 15, // max tags per artwork
|
||||
'banned' => [], // blocked slugs (add to env-driven list)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
PHP: `tests/Feature/TagSystemTest.php`
|
||||
|
||||
Covers: normalization, duplicate prevention, AI attach, sync, usage counts, force-delete cleanup.
|
||||
|
||||
JS: `resources/js/components/tags/TagInput.test.jsx`
|
||||
|
||||
Covers: add/remove, keyboard accept, paste, API failure, max-tags limit.
|
||||
|
||||
Run:
|
||||
```bash
|
||||
php artisan test --filter=TagSystem
|
||||
npm test -- TagInput
|
||||
```
|
||||
@@ -7,6 +7,9 @@
|
||||
"dev": "vite",
|
||||
"test:ui": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:routes": "playwright test tests/e2e/routes.spec.ts",
|
||||
"test:routes:headed": "playwright test tests/e2e/routes.spec.ts --headed",
|
||||
"test:routes:report": "playwright test tests/e2e/routes.spec.ts --reporter=html && playwright show-report playwright-report",
|
||||
"playwright:install": "npx playwright install"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -31,5 +31,6 @@
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
<env name="SCOUT_DRIVER" value="null"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
85
playwright-report/index.html
Normal file
85
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
@@ -4,16 +4,47 @@ export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
timeout: 30000,
|
||||
expect: { timeout: 5000 },
|
||||
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
|
||||
/* Retry once on CI to reduce flakiness from cold-start */
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
|
||||
/* Limit concurrency so the dev server isn't overwhelmed */
|
||||
workers: process.env.CI ? 2 : 4,
|
||||
|
||||
/* Reporters: dot in CI, list + HTML locally */
|
||||
reporter: process.env.CI
|
||||
? [['dot'], ['json', { outputFile: 'test-results/playwright-results.json' }]]
|
||||
: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
|
||||
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://skinbase26.test',
|
||||
headless: true,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
ignoreHTTPSErrors: true,
|
||||
|
||||
/* Capture trace, screenshot and video only on failure */
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'off',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
/* Uncomment to add Firefox/WebKit coverage:
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
*/
|
||||
],
|
||||
});
|
||||
|
||||
BIN
public/gfx/sb_join.jpg
Normal file
BIN
public/gfx/sb_join.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -1,43 +1,118 @@
|
||||
import React from 'react'
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import ArtworkHero from '../components/artwork/ArtworkHero'
|
||||
import ArtworkMeta from '../components/artwork/ArtworkMeta'
|
||||
import ArtworkActions from '../components/artwork/ArtworkActions'
|
||||
import ArtworkAwards from '../components/artwork/ArtworkAwards'
|
||||
import ArtworkStats from '../components/artwork/ArtworkStats'
|
||||
import ArtworkTags from '../components/artwork/ArtworkTags'
|
||||
import ArtworkAuthor from '../components/artwork/ArtworkAuthor'
|
||||
import ArtworkRelated from '../components/artwork/ArtworkRelated'
|
||||
import ArtworkDescription from '../components/artwork/ArtworkDescription'
|
||||
import ArtworkComments from '../components/artwork/ArtworkComments'
|
||||
import ArtworkNavigator from '../components/viewer/ArtworkNavigator'
|
||||
import ArtworkViewer from '../components/viewer/ArtworkViewer'
|
||||
|
||||
function ArtworkPage({ artwork: initialArtwork, related: initialRelated, presentMd: initialMd, presentLg: initialLg, presentXl: initialXl, presentSq: initialSq, canonicalUrl: initialCanonical, isAuthenticated = false, comments: initialComments = [] }) {
|
||||
const [viewerOpen, setViewerOpen] = useState(false)
|
||||
const openViewer = useCallback(() => setViewerOpen(true), [])
|
||||
const closeViewer = useCallback(() => setViewerOpen(false), [])
|
||||
|
||||
// Navigable state — updated on client-side navigation
|
||||
const [artwork, setArtwork] = useState(initialArtwork)
|
||||
const [presentMd, setPresentMd] = useState(initialMd)
|
||||
const [presentLg, setPresentLg] = useState(initialLg)
|
||||
const [presentXl, setPresentXl] = useState(initialXl)
|
||||
const [presentSq, setPresentSq] = useState(initialSq)
|
||||
const [related, setRelated] = useState(initialRelated)
|
||||
const [comments, setComments] = useState(initialComments)
|
||||
const [canonicalUrl, setCanonicalUrl] = useState(initialCanonical)
|
||||
|
||||
// Nav arrow state — populated by ArtworkNavigator once neighbors resolve
|
||||
const [navState, setNavState] = useState({ hasPrev: false, hasNext: false, navigatePrev: null, navigateNext: null })
|
||||
|
||||
/**
|
||||
* Called by ArtworkNavigator after a successful no-reload navigation.
|
||||
* data = ArtworkResource JSON from /api/artworks/{id}/page
|
||||
*/
|
||||
const handleNavigate = useCallback((data) => {
|
||||
setArtwork(data)
|
||||
setPresentMd(data.thumbs?.md ?? null)
|
||||
setPresentLg(data.thumbs?.lg ?? null)
|
||||
setPresentXl(data.thumbs?.xl ?? null)
|
||||
setPresentSq(data.thumbs?.sq ?? null)
|
||||
setRelated([]) // cleared on navigation; user can scroll down for related
|
||||
setComments([]) // cleared; per-page server data
|
||||
setCanonicalUrl(data.canonical_url ?? window.location.href)
|
||||
setViewerOpen(false) // close viewer when navigating away
|
||||
}, [])
|
||||
|
||||
function ArtworkPage({ artwork, related, presentMd, presentLg, presentXl, presentSq, canonicalUrl }) {
|
||||
if (!artwork) return null
|
||||
|
||||
const initialAwards = artwork?.awards ?? null
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-screen-xl px-4 pb-24 pt-10 sm:px-6 lg:px-8 lg:pb-12">
|
||||
<ArtworkHero artwork={artwork} presentMd={presentMd} presentLg={presentLg} presentXl={presentXl} />
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-screen-xl px-4 pb-24 pt-10 sm:px-6 lg:px-8 lg:pb-12">
|
||||
<ArtworkHero
|
||||
artwork={artwork}
|
||||
presentMd={presentMd}
|
||||
presentLg={presentLg}
|
||||
presentXl={presentXl}
|
||||
onOpenViewer={openViewer}
|
||||
hasPrev={navState.hasPrev}
|
||||
hasNext={navState.hasNext}
|
||||
onPrev={navState.navigatePrev}
|
||||
onNext={navState.navigateNext}
|
||||
/>
|
||||
|
||||
<div className="mt-6 lg:hidden">
|
||||
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} mobilePriority />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<ArtworkMeta artwork={artwork} />
|
||||
<ArtworkAuthor artwork={artwork} presentSq={presentSq} />
|
||||
<ArtworkStats artwork={artwork} />
|
||||
<ArtworkTags artwork={artwork} />
|
||||
<ArtworkDescription artwork={artwork} />
|
||||
<div className="mt-6 lg:hidden">
|
||||
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} mobilePriority />
|
||||
<div className="mt-4">
|
||||
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="hidden space-y-6 lg:block">
|
||||
<div className="sticky top-24">
|
||||
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} />
|
||||
<div className="mt-8 grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<ArtworkMeta artwork={artwork} />
|
||||
<ArtworkAuthor artwork={artwork} presentSq={presentSq} />
|
||||
<ArtworkStats artwork={artwork} />
|
||||
<ArtworkTags artwork={artwork} />
|
||||
<ArtworkDescription artwork={artwork} />
|
||||
<ArtworkComments comments={comments} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<ArtworkRelated related={related} />
|
||||
</main>
|
||||
<aside className="hidden space-y-6 lg:block">
|
||||
<div className="sticky top-24">
|
||||
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} />
|
||||
<div className="mt-4">
|
||||
<ArtworkAwards artwork={artwork} initialAwards={initialAwards} isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<ArtworkRelated related={related} />
|
||||
</main>
|
||||
|
||||
{/* Artwork navigator — prev/next arrows, keyboard, swipe, no page reload */}
|
||||
<ArtworkNavigator
|
||||
artworkId={artwork.id}
|
||||
onNavigate={handleNavigate}
|
||||
onOpenViewer={openViewer}
|
||||
onReady={setNavState}
|
||||
/>
|
||||
|
||||
{/* Fullscreen viewer modal */}
|
||||
<ArtworkViewer
|
||||
isOpen={viewerOpen}
|
||||
onClose={closeViewer}
|
||||
artwork={artwork}
|
||||
presentLg={presentLg}
|
||||
presentXl={presentXl}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,6 +137,8 @@ if (el) {
|
||||
presentXl={parse('presentXl')}
|
||||
presentSq={parse('presentSq')}
|
||||
canonicalUrl={parse('canonical', '')}
|
||||
isAuthenticated={parse('isAuthenticated', false)}
|
||||
comments={parse('comments', [])}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
156
resources/js/Search/SearchBar.jsx
Normal file
156
resources/js/Search/SearchBar.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
const SEARCH_API = '/api/search/artworks'
|
||||
const DEBOUNCE_MS = 280
|
||||
|
||||
function useDebounce(value, delay) {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(value), delay)
|
||||
return () => clearTimeout(id)
|
||||
}, [value, delay])
|
||||
return debounced
|
||||
}
|
||||
|
||||
export default function SearchBar({ placeholder = 'Search artworks, artists, tags…' }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [suggestions, setSuggestions] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const inputRef = useRef(null)
|
||||
const wrapperRef = useRef(null)
|
||||
const abortRef = useRef(null)
|
||||
|
||||
const debouncedQuery = useDebounce(query, DEBOUNCE_MS)
|
||||
|
||||
const fetchSuggestions = useCallback(async (q) => {
|
||||
if (!q || q.length < 2) {
|
||||
setSuggestions([])
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (abortRef.current) abortRef.current.abort()
|
||||
abortRef.current = new AbortController()
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = `${SEARCH_API}?q=${encodeURIComponent(q)}&per_page=6`
|
||||
const res = await fetch(url, { signal: abortRef.current.signal })
|
||||
if (!res.ok) return
|
||||
const json = await res.json()
|
||||
const items = json.data ?? json ?? []
|
||||
setSuggestions(Array.isArray(items) ? items.slice(0, 6) : [])
|
||||
setOpen(true)
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') console.error('SearchBar fetch error', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSuggestions(debouncedQuery)
|
||||
}, [debouncedQuery, fetchSuggestions])
|
||||
|
||||
// Close suggestions on outside click
|
||||
useEffect(() => {
|
||||
function handler(e) {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (query.trim()) {
|
||||
window.location.href = `/search?q=${encodeURIComponent(query.trim())}`
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(item) {
|
||||
window.location.href = item.urls?.web ?? `/${item.slug ?? ''}`
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative w-full">
|
||||
<form onSubmit={handleSubmit} role="search" className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onFocus={() => suggestions.length > 0 && setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
aria-label="Search"
|
||||
autoComplete="off"
|
||||
className="w-full bg-nova-900 border border-nova-800 rounded-lg py-2.5 pl-3.5 pr-10 text-white placeholder-soft outline-none focus:border-accent transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Submit search"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-soft hover:text-accent transition-colors"
|
||||
>
|
||||
{loading
|
||||
? <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/></svg>
|
||||
: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/></svg>
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{open && suggestions.length > 0 && (
|
||||
<ul
|
||||
role="listbox"
|
||||
aria-label="Search suggestions"
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-nova-900 border border-nova-800 rounded-xl shadow-2xl overflow-hidden z-50"
|
||||
>
|
||||
{suggestions.map((item) => (
|
||||
<li key={item.slug} role="option">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(item)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-white/[0.06] text-left transition-colors"
|
||||
>
|
||||
{item.thumbnail_url && (
|
||||
<img
|
||||
src={item.thumbnail_url}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="w-10 h-10 rounded object-cover shrink-0 bg-nova-900"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-white truncate">{item.title}</div>
|
||||
{item.author?.name && (
|
||||
<div className="text-xs text-neutral-400 truncate">{item.author.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li className="border-t border-nova-900">
|
||||
<a
|
||||
href={`/search?q=${encodeURIComponent(query)}`}
|
||||
className="flex items-center justify-center gap-1.5 px-3 py-2 text-xs text-accent hover:text-accent/80 transition-colors"
|
||||
>
|
||||
See all results for <span className="font-semibold">"{query}"</span>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import SearchBar from '../Search/SearchBar'
|
||||
|
||||
export default function Topbar() {
|
||||
return (
|
||||
@@ -12,11 +13,7 @@ export default function Topbar() {
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block flex-1 max-w-xl">
|
||||
<form action="/search" method="get" className="relative">
|
||||
<input name="q" aria-label="Search" placeholder="Search tags, artworks, artists…"
|
||||
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg py-2.5 pl-3.5 pr-10 text-white outline-none focus:border-sky-400" />
|
||||
<i className="fas fa-search absolute right-3.5 top-1/2 -translate-y-1/2 text-neutral-400" aria-hidden="true"></i>
|
||||
</form>
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 sm:gap-5">
|
||||
@@ -29,3 +26,4 @@ export default function Topbar() {
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
191
resources/js/components/artwork/ArtworkAwards.jsx
Normal file
191
resources/js/components/artwork/ArtworkAwards.jsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
|
||||
const MEDALS = [
|
||||
{ key: 'gold', label: 'Gold', emoji: '🥇', weight: 3 },
|
||||
{ key: 'silver', label: 'Silver', emoji: '🥈', weight: 2 },
|
||||
{ key: 'bronze', label: 'Bronze', emoji: '🥉', weight: 1 },
|
||||
]
|
||||
|
||||
export default function ArtworkAwards({ artwork, initialAwards = null, isAuthenticated = false }) {
|
||||
const artworkId = artwork?.id
|
||||
|
||||
const [awards, setAwards] = useState({
|
||||
gold: initialAwards?.gold ?? 0,
|
||||
silver: initialAwards?.silver ?? 0,
|
||||
bronze: initialAwards?.bronze ?? 0,
|
||||
score: initialAwards?.score ?? 0,
|
||||
})
|
||||
const [viewerAward, setViewerAward] = useState(initialAwards?.viewer_award ?? null)
|
||||
const [loading, setLoading] = useState(null) // which medal is pending
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
const apiFetch = useCallback(async (method, body = null) => {
|
||||
const res = await fetch(`/api/artworks/${artworkId}/award`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data?.message || data?.errors?.medal?.[0] || 'Request failed')
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}, [artworkId, csrfToken])
|
||||
|
||||
const applyServerResponse = useCallback((data) => {
|
||||
if (data?.awards) {
|
||||
setAwards({
|
||||
gold: data.awards.gold ?? 0,
|
||||
silver: data.awards.silver ?? 0,
|
||||
bronze: data.awards.bronze ?? 0,
|
||||
score: data.awards.score ?? 0,
|
||||
})
|
||||
}
|
||||
setViewerAward(data?.viewer_award ?? null)
|
||||
}, [])
|
||||
|
||||
const handleMedalClick = useCallback(async (medal) => {
|
||||
if (!isAuthenticated) return
|
||||
if (loading) return
|
||||
|
||||
setError(null)
|
||||
|
||||
// Optimistic update
|
||||
const prevAwards = { ...awards }
|
||||
const prevViewer = viewerAward
|
||||
|
||||
const delta = (m) => {
|
||||
const weight = MEDALS.find(x => x.key === m)?.weight ?? 0
|
||||
return weight
|
||||
}
|
||||
|
||||
if (viewerAward === medal) {
|
||||
// Undo: remove award
|
||||
setAwards(a => ({
|
||||
...a,
|
||||
[medal]: Math.max(0, a[medal] - 1),
|
||||
score: Math.max(0, a.score - delta(medal)),
|
||||
}))
|
||||
setViewerAward(null)
|
||||
|
||||
setLoading(medal)
|
||||
try {
|
||||
const data = await apiFetch('DELETE')
|
||||
applyServerResponse(data)
|
||||
} catch (e) {
|
||||
setAwards(prevAwards)
|
||||
setViewerAward(prevViewer)
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
} else if (viewerAward) {
|
||||
// Change: swap medals
|
||||
const prev = viewerAward
|
||||
setAwards(a => ({
|
||||
...a,
|
||||
[prev]: Math.max(0, a[prev] - 1),
|
||||
[medal]: a[medal] + 1,
|
||||
score: a.score - delta(prev) + delta(medal),
|
||||
}))
|
||||
setViewerAward(medal)
|
||||
|
||||
setLoading(medal)
|
||||
try {
|
||||
const data = await apiFetch('PUT', { medal })
|
||||
applyServerResponse(data)
|
||||
} catch (e) {
|
||||
setAwards(prevAwards)
|
||||
setViewerAward(prevViewer)
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
} else {
|
||||
// New award
|
||||
setAwards(a => ({
|
||||
...a,
|
||||
[medal]: a[medal] + 1,
|
||||
score: a.score + delta(medal),
|
||||
}))
|
||||
setViewerAward(medal)
|
||||
|
||||
setLoading(medal)
|
||||
try {
|
||||
const data = await apiFetch('POST', { medal })
|
||||
applyServerResponse(data)
|
||||
} catch (e) {
|
||||
setAwards(prevAwards)
|
||||
setViewerAward(prevViewer)
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, loading, awards, viewerAward, apiFetch, applyServerResponse])
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-nova-700 bg-panel p-5 shadow-lg shadow-deep/30">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Awards</h2>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-xs text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row">
|
||||
{MEDALS.map(({ key, label, emoji }) => {
|
||||
const isActive = viewerAward === key
|
||||
const isPending = loading === key
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
disabled={!isAuthenticated || loading !== null}
|
||||
onClick={() => handleMedalClick(key)}
|
||||
title={!isAuthenticated ? 'Sign in to award' : isActive ? `Remove ${label} award` : `Award ${label}`}
|
||||
className={[
|
||||
'inline-flex min-h-11 flex-1 flex-col items-center justify-center gap-1 rounded-lg border px-3 py-2 text-sm transition-all',
|
||||
isActive
|
||||
? 'border-accent bg-accent/10 font-semibold text-accent'
|
||||
: 'border-nova-600 text-white hover:bg-nova-800',
|
||||
(!isAuthenticated || loading !== null) && 'cursor-not-allowed opacity-60',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<span className="text-xl leading-none" aria-hidden="true">
|
||||
{isPending ? '…' : emoji}
|
||||
</span>
|
||||
<span className="text-xs font-medium leading-none">{label}</span>
|
||||
<span className="text-xs text-soft tabular-nums">
|
||||
{awards[key]}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{awards.score > 0 && (
|
||||
<p className="mt-3 text-right text-xs text-soft">
|
||||
Score: <span className="font-semibold text-white">{awards.score}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isAuthenticated && (
|
||||
<p className="mt-3 text-center text-xs text-soft">
|
||||
<a href="/login" className="text-accent hover:underline">Sign in</a> to award this artwork
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
resources/js/components/artwork/ArtworkBreadcrumbs.jsx
Normal file
88
resources/js/components/artwork/ArtworkBreadcrumbs.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react'
|
||||
|
||||
function Separator() {
|
||||
return (
|
||||
<svg
|
||||
className="h-3 w-3 flex-shrink-0 text-white/15"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Crumb({ href, children, current = false }) {
|
||||
const base = 'text-xs leading-none truncate max-w-[180px] sm:max-w-[260px]'
|
||||
if (current) {
|
||||
return (
|
||||
<span
|
||||
className={`${base} text-white/30`}
|
||||
aria-current="page"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={`${base} text-white/30 hover:text-white/60 transition-colors duration-150`}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkBreadcrumbs({ artwork }) {
|
||||
if (!artwork) return null
|
||||
|
||||
// Use the first category for the content-type + category crumbs
|
||||
const firstCategory = artwork.categories?.[0] ?? null
|
||||
const contentTypeSlug = firstCategory?.content_type_slug || null
|
||||
const contentTypeName = contentTypeSlug
|
||||
? contentTypeSlug.charAt(0).toUpperCase() + contentTypeSlug.slice(1)
|
||||
: null
|
||||
|
||||
const categorySlug = firstCategory?.slug || null
|
||||
const categoryName = firstCategory?.name || null
|
||||
const categoryUrl = contentTypeSlug && categorySlug
|
||||
? `/${contentTypeSlug}/${categorySlug}`
|
||||
: null
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="mt-1.5 mb-0">
|
||||
<ol className="flex flex-wrap items-center gap-x-1 gap-y-1">
|
||||
{/* Home */}
|
||||
<li className="flex items-center gap-x-1.5">
|
||||
<Crumb href="/">Home</Crumb>
|
||||
</li>
|
||||
|
||||
{/* Content type e.g. Photography */}
|
||||
{contentTypeSlug && (
|
||||
<>
|
||||
<li className="flex items-center"><Separator /></li>
|
||||
<li className="flex items-center gap-x-1.5">
|
||||
<Crumb href={`/${contentTypeSlug}`}>{contentTypeName}</Crumb>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Category e.g. Landscapes */}
|
||||
{categoryUrl && (
|
||||
<>
|
||||
<li className="flex items-center"><Separator /></li>
|
||||
<li className="flex items-center gap-x-1.5">
|
||||
<Crumb href={categoryUrl}>{categoryName}</Crumb>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Current artwork title — omitted: shown as h1 above */}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
97
resources/js/components/artwork/ArtworkComments.jsx
Normal file
97
resources/js/components/artwork/ArtworkComments.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from 'react'
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 365) return `${days}d ago`
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function Avatar({ user, size = 36 }) {
|
||||
if (user?.avatar_url) {
|
||||
return (
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt={user.name || user.username || ''}
|
||||
width={size}
|
||||
height={size}
|
||||
className="rounded-full object-cover shrink-0"
|
||||
style={{ width: size, height: size }}
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
}
|
||||
const initials = (user?.name || user?.username || '?').slice(0, 1).toUpperCase()
|
||||
return (
|
||||
<span
|
||||
className="flex items-center justify-center rounded-full bg-neutral-700 text-sm font-bold text-white shrink-0"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkComments({ comments = [] }) {
|
||||
if (!comments || comments.length === 0) return null
|
||||
|
||||
return (
|
||||
<section aria-label="Comments">
|
||||
<h2 className="text-base font-semibold text-white mb-4">
|
||||
Comments{' '}
|
||||
<span className="text-neutral-500 font-normal">({comments.length})</span>
|
||||
</h2>
|
||||
|
||||
<ul className="space-y-5">
|
||||
{comments.map((comment) => (
|
||||
<li key={comment.id} className="flex gap-3">
|
||||
{comment.user?.profile_url ? (
|
||||
<a href={comment.user.profile_url} className="shrink-0 mt-0.5">
|
||||
<Avatar user={comment.user} size={36} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="shrink-0 mt-0.5">
|
||||
<Avatar user={comment.user} size={36} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
{comment.user?.profile_url ? (
|
||||
<a
|
||||
href={comment.user.profile_url}
|
||||
className="text-sm font-medium text-white hover:underline"
|
||||
>
|
||||
{comment.user.name || comment.user.username || 'Member'}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-white">
|
||||
{comment.user?.name || comment.user?.username || 'Member'}
|
||||
</span>
|
||||
)}
|
||||
<time
|
||||
dateTime={comment.created_at}
|
||||
title={comment.created_at ? new Date(comment.created_at).toLocaleString() : ''}
|
||||
className="text-xs text-neutral-500"
|
||||
>
|
||||
{timeAgo(comment.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-sm text-neutral-300 whitespace-pre-line break-words leading-relaxed">
|
||||
{comment.content}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const FALLBACK_LG = 'https://files.skinbase.org/default/missing_lg.webp'
|
||||
const FALLBACK_XL = 'https://files.skinbase.org/default/missing_xl.webp'
|
||||
|
||||
export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl }) {
|
||||
export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl, onOpenViewer, hasPrev, hasNext, onPrev, onNext }) {
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
|
||||
const mdSource = presentMd?.url || artwork?.thumbs?.md?.url || null
|
||||
@@ -23,24 +23,20 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl }
|
||||
return (
|
||||
<figure className="w-full">
|
||||
<div className="relative mx-auto w-full max-w-[1280px]">
|
||||
{blurBackdropSrc && (
|
||||
<div className="pointer-events-none absolute inset-0 -z-10 scale-105 overflow-hidden rounded-2xl">
|
||||
<img
|
||||
src={blurBackdropSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover opacity-35 blur-2xl"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{hasRealArtworkImage && (
|
||||
<div className="absolute inset-0 -z-10 rounded-2xl bg-gradient-to-b from-nova-700/20 via-nova-900/15 to-deep/40" />
|
||||
<div className="absolute inset-0 -z-10" />
|
||||
)}
|
||||
|
||||
<div className="relative w-full aspect-video rounded-xl overflow-hidden bg-deep shadow-2xl ring-1 ring-nova-600/30">
|
||||
<div
|
||||
className={`relative w-full aspect-video overflow-hidden ${onOpenViewer ? 'cursor-zoom-in' : ''}`}
|
||||
onClick={onOpenViewer}
|
||||
role={onOpenViewer ? 'button' : undefined}
|
||||
aria-label={onOpenViewer ? 'View fullscreen' : undefined}
|
||||
tabIndex={onOpenViewer ? 0 : undefined}
|
||||
onKeyDown={onOpenViewer ? (e) => e.key === 'Enter' && onOpenViewer() : undefined}
|
||||
>
|
||||
<img
|
||||
src={md}
|
||||
alt={artwork?.title ?? 'Artwork'}
|
||||
@@ -62,6 +58,47 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl }
|
||||
event.currentTarget.src = FALLBACK_LG
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Prev arrow */}
|
||||
{hasPrev && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous artwork"
|
||||
onClick={(e) => { e.stopPropagation(); onPrev?.(); }}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/50 text-white/70 backdrop-blur-sm ring-1 ring-white/15 shadow-lg opacity-50 hover:opacity-100 focus:opacity-100 transition-opacity duration-150"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Next arrow */}
|
||||
{hasNext && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next artwork"
|
||||
onClick={(e) => { e.stopPropagation(); onNext?.(); }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/50 text-white/70 backdrop-blur-sm ring-1 ring-white/15 shadow-lg opacity-50 hover:opacity-100 focus:opacity-100 transition-opacity duration-150"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onOpenViewer && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="View fullscreen"
|
||||
onClick={(e) => { e.stopPropagation(); onOpenViewer(); }}
|
||||
className="absolute bottom-3 right-3 flex h-9 w-9 items-center justify-center rounded-full bg-black/50 text-white/80 backdrop-blur-sm ring-1 ring-white/15 opacity-0 hover:opacity-100 focus:opacity-100 [div:hover_&]:opacity-100 transition-opacity duration-150 shadow-lg"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasRealArtworkImage && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import ArtworkBreadcrumbs from './ArtworkBreadcrumbs'
|
||||
|
||||
export default function ArtworkMeta({ artwork }) {
|
||||
const author = artwork?.user?.name || artwork?.user?.username || 'Artist'
|
||||
@@ -11,7 +12,8 @@ export default function ArtworkMeta({ artwork }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-nova-700 bg-panel p-5">
|
||||
<h1 className="text-xl font-semibold text-white sm:text-2xl">{artwork?.title}</h1>
|
||||
<dl className="mt-4 grid grid-cols-1 gap-3 text-sm text-soft sm:grid-cols-2">
|
||||
<ArtworkBreadcrumbs artwork={artwork} />
|
||||
<dl className="mt-3 grid grid-cols-1 gap-3 text-sm text-soft sm:grid-cols-2">
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg bg-nova-900/30 px-3 py-2">
|
||||
<dt>Author</dt>
|
||||
<dd className="text-white">{author}</dd>
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function ArtworkTags({ artwork }) {
|
||||
const artworkTags = (artwork?.tags || []).map((tag) => ({
|
||||
key: `tag-${tag.id || tag.slug}`,
|
||||
label: tag.name,
|
||||
href: `/browse/${primaryCategorySlug}/${tag.slug || ''}`,
|
||||
href: `/tag/${tag.slug || ''}`,
|
||||
}))
|
||||
|
||||
return [...categories, ...artworkTags]
|
||||
|
||||
159
resources/js/components/viewer/ArtworkNavigator.jsx
Normal file
159
resources/js/components/viewer/ArtworkNavigator.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* ArtworkNavigator
|
||||
*
|
||||
* Behavior-only: prev/next navigation WITHOUT page reload.
|
||||
* Features: fetch + history.pushState, Image() preloading, keyboard (← →/F), touch swipe.
|
||||
* UI arrows are rendered by ArtworkHero via onReady callback.
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavContext } from '../../lib/useNavContext';
|
||||
|
||||
const preloadCache = new Set();
|
||||
|
||||
function preloadImage(src) {
|
||||
if (!src || preloadCache.has(src)) return;
|
||||
preloadCache.add(src);
|
||||
const img = new Image();
|
||||
img.src = src;
|
||||
}
|
||||
|
||||
export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer, onReady }) {
|
||||
const { getNeighbors } = useNavContext(artworkId);
|
||||
const [neighbors, setNeighbors] = useState({ prevId: null, nextId: null, prevUrl: null, nextUrl: null });
|
||||
|
||||
// Refs so navigate/keyboard/swipe callbacks are stable (no dep on state values)
|
||||
const navigatingRef = useRef(false);
|
||||
const neighborsRef = useRef(neighbors);
|
||||
const onNavigateRef = useRef(onNavigate);
|
||||
const onOpenViewerRef = useRef(onOpenViewer);
|
||||
const onReadyRef = useRef(onReady);
|
||||
|
||||
// Keep refs in sync with latest props/state
|
||||
useEffect(() => { neighborsRef.current = neighbors; }, [neighbors]);
|
||||
useEffect(() => { onNavigateRef.current = onNavigate; }, [onNavigate]);
|
||||
useEffect(() => { onOpenViewerRef.current = onOpenViewer; }, [onOpenViewer]);
|
||||
useEffect(() => { onReadyRef.current = onReady; }, [onReady]);
|
||||
|
||||
const touchStartX = useRef(null);
|
||||
const touchStartY = useRef(null);
|
||||
|
||||
// Resolve neighbors on mount / artworkId change
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getNeighbors().then((n) => {
|
||||
if (cancelled) return;
|
||||
setNeighbors(n);
|
||||
[n.prevId, n.nextId].forEach((id) => {
|
||||
if (!id) return;
|
||||
fetch(`/api/artworks/${id}/page`, { headers: { Accept: 'application/json' } })
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => {
|
||||
if (!data) return;
|
||||
const imgUrl = data.thumbs?.lg?.url || data.thumbs?.md?.url;
|
||||
if (imgUrl) preloadImage(imgUrl);
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [artworkId, getNeighbors]);
|
||||
|
||||
// Stable navigate — reads state via refs, never recreated
|
||||
const navigate = useCallback(async (targetId, targetUrl) => {
|
||||
if (!targetId && !targetUrl) return;
|
||||
if (navigatingRef.current) return;
|
||||
|
||||
const fallbackUrl = targetUrl || `/art/${targetId}`;
|
||||
const currentOnNavigate = onNavigateRef.current;
|
||||
|
||||
if (!currentOnNavigate || !targetId) {
|
||||
window.location.href = fallbackUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
navigatingRef.current = true;
|
||||
try {
|
||||
const res = await fetch(`/api/artworks/${targetId}/page`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
const canonicalSlug =
|
||||
(data.slug || data.title || String(data.id))
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '') || String(data.id);
|
||||
|
||||
history.pushState({ artworkId: data.id }, '', `/art/${data.id}/${canonicalSlug}`);
|
||||
document.title = `${data.title} | Skinbase`;
|
||||
|
||||
currentOnNavigate(data);
|
||||
} catch {
|
||||
window.location.href = fallbackUrl;
|
||||
} finally {
|
||||
navigatingRef.current = false;
|
||||
}
|
||||
}, []); // stable — accesses everything via refs
|
||||
|
||||
// Notify parent whenever neighbors change
|
||||
useEffect(() => {
|
||||
const hasPrev = Boolean(neighbors.prevId || neighbors.prevUrl);
|
||||
const hasNext = Boolean(neighbors.nextId || neighbors.nextUrl);
|
||||
onReadyRef.current?.({
|
||||
hasPrev,
|
||||
hasNext,
|
||||
navigatePrev: hasPrev ? () => navigate(neighbors.prevId, neighbors.prevUrl) : null,
|
||||
navigateNext: hasNext ? () => navigate(neighbors.nextId, neighbors.nextUrl) : null,
|
||||
});
|
||||
}, [neighbors, navigate]);
|
||||
|
||||
// Sync browser back/forward
|
||||
useEffect(() => {
|
||||
function onPop() { window.location.reload(); }
|
||||
window.addEventListener('popstate', onPop);
|
||||
return () => window.removeEventListener('popstate', onPop);
|
||||
}, []);
|
||||
|
||||
// Keyboard: ← → navigate, F fullscreen
|
||||
useEffect(() => {
|
||||
function onKey(e) {
|
||||
const tag = e.target?.tagName?.toLowerCase?.() ?? '';
|
||||
if (['input', 'textarea', 'select'].includes(tag) || e.target?.isContentEditable) return;
|
||||
const n = neighborsRef.current;
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); navigate(n.prevId, n.prevUrl); }
|
||||
else if (e.key === 'ArrowRight') { e.preventDefault(); navigate(n.nextId, n.nextUrl); }
|
||||
else if ((e.key === 'f' || e.key === 'F') && !e.ctrlKey && !e.metaKey) { onOpenViewerRef.current?.(); }
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [navigate]); // navigate is stable so this only runs once
|
||||
|
||||
// Touch swipe
|
||||
useEffect(() => {
|
||||
function onTouchStart(e) {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
}
|
||||
function onTouchEnd(e) {
|
||||
if (touchStartX.current === null) return;
|
||||
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||||
const dy = e.changedTouches[0].clientY - touchStartY.current;
|
||||
touchStartX.current = null;
|
||||
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
|
||||
const n = neighborsRef.current;
|
||||
if (dx > 0) navigate(n.prevId, n.prevUrl);
|
||||
else navigate(n.nextId, n.nextUrl);
|
||||
}
|
||||
}
|
||||
window.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
window.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener('touchstart', onTouchStart);
|
||||
window.removeEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
}, [navigate]); // stable
|
||||
|
||||
return null;
|
||||
}
|
||||
96
resources/js/components/viewer/ArtworkViewer.jsx
Normal file
96
resources/js/components/viewer/ArtworkViewer.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* ArtworkViewer
|
||||
*
|
||||
* Fullscreen image modal. Opens on image click or keyboard F.
|
||||
* Controls: ESC to close, click outside to close.
|
||||
*/
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
export default function ArtworkViewer({ isOpen, onClose, artwork, presentLg, presentXl }) {
|
||||
const dialogRef = useRef(null);
|
||||
|
||||
// Resolve best quality source
|
||||
const imgSrc =
|
||||
presentXl?.url ||
|
||||
presentLg?.url ||
|
||||
artwork?.thumbs?.xl?.url ||
|
||||
artwork?.thumbs?.lg?.url ||
|
||||
artwork?.thumb ||
|
||||
null;
|
||||
|
||||
// ESC to close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Lock scroll while open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Focus the dialog for accessibility
|
||||
requestAnimationFrame(() => dialogRef.current?.focus());
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen || !imgSrc) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Fullscreen artwork viewer"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm outline-none"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-black/60 text-white/70 ring-1 ring-white/15 transition-colors hover:bg-black/80 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
onClick={onClose}
|
||||
aria-label="Close viewer (Esc)"
|
||||
type="button"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Image — stopPropagation so clicking image doesn't close modal */}
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={artwork?.title ?? 'Artwork'}
|
||||
className="max-h-[90vh] max-w-[90vw] rounded-xl object-contain shadow-2xl shadow-black/60 select-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
draggable={false}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
|
||||
{/* Title / author footer */}
|
||||
{artwork?.title && (
|
||||
<div
|
||||
className="absolute bottom-5 left-1/2 -translate-x-1/2 max-w-[70vw] truncate rounded-lg bg-black/65 px-4 py-2 text-center text-sm text-white backdrop-blur-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{artwork.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ESC hint */}
|
||||
<span className="pointer-events-none absolute bottom-5 right-5 text-xs text-white/30 select-none">
|
||||
ESC to close
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
439
resources/js/components/viewer/viewer.test.jsx
Normal file
439
resources/js/components/viewer/viewer.test.jsx
Normal file
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Artwork Viewer System Tests
|
||||
*
|
||||
* Covers the 5 spec-required test cases:
|
||||
* 1. Context navigation test — prev/next resolved from sessionStorage
|
||||
* 2. Fallback test — API fallback when no sessionStorage context
|
||||
* 3. Keyboard test — ← → keys navigate; ESC closes viewer; F opens viewer
|
||||
* 4. Mobile swipe test — horizontal swipe triggers navigation
|
||||
* 5. Modal test — viewer opens/closes via image click and keyboard
|
||||
*/
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeCtx(overrides = {}) {
|
||||
return JSON.stringify({
|
||||
source: 'tag',
|
||||
key: 'tag:digital-art',
|
||||
ids: [100, 200, 300],
|
||||
index: 1,
|
||||
ts: Date.now(),
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function mockSessionStorage(value) {
|
||||
const store = { nav_ctx: value }
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => store[key] ?? null)
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {})
|
||||
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {})
|
||||
}
|
||||
|
||||
function mockFetch(data, ok = true) {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
status: ok ? 200 : 404,
|
||||
json: async () => data,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 1. Context Navigation Test ───────────────────────────────────────────────
|
||||
|
||||
describe('Context navigation — useNavContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('resolves prev/next IDs from the same-user API', async () => {
|
||||
const apiData = { prev_id: 100, next_id: 300, prev_url: '/art/100', next_url: '/art/300' }
|
||||
mockFetch(apiData)
|
||||
|
||||
const { useNavContext } = await import('../../lib/useNavContext')
|
||||
|
||||
function Harness() {
|
||||
const { getNeighbors } = useNavContext(200)
|
||||
const [n, setN] = React.useState(null)
|
||||
React.useEffect(() => { getNeighbors().then(setN) }, [getNeighbors])
|
||||
return n ? <div data-testid="result">{n.prevId}-{n.nextId}</div> : null
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
|
||||
expect(screen.getByTestId('result').textContent).toBe('100-300')
|
||||
})
|
||||
|
||||
it('returns null neighbors when the artwork has no same-user neighbors', async () => {
|
||||
const apiData = { prev_id: null, next_id: null, prev_url: null, next_url: null }
|
||||
mockFetch(apiData)
|
||||
|
||||
const { useNavContext } = await import('../../lib/useNavContext')
|
||||
|
||||
function Harness() {
|
||||
const { getNeighbors } = useNavContext(100)
|
||||
const [n, setN] = React.useState(null)
|
||||
React.useEffect(() => { getNeighbors().then(setN) }, [getNeighbors])
|
||||
return n ? <div data-testid="result">{String(n.prevId)}|{String(n.nextId)}</div> : null
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
|
||||
expect(screen.getByTestId('result').textContent).toBe('null|null')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Fallback Test ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Fallback — API navigation when no sessionStorage context', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('calls /api/artworks/navigation/{id} when sessionStorage is empty', async () => {
|
||||
mockSessionStorage(null)
|
||||
const apiData = { prev_id: 50, next_id: 150, prev_url: '/art/50', next_url: '/art/150' }
|
||||
mockFetch(apiData)
|
||||
|
||||
const { useNavContext } = await import('../../lib/useNavContext')
|
||||
|
||||
let result
|
||||
function Harness() {
|
||||
const { getNeighbors } = useNavContext(100)
|
||||
const [n, setN] = React.useState(null)
|
||||
React.useEffect(() => {
|
||||
getNeighbors().then(setN)
|
||||
}, [getNeighbors])
|
||||
return n ? <div data-testid="result">{n.prevId}-{n.nextId}</div> : null
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/artworks/navigation/100'),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(screen.getByTestId('result').textContent).toBe('50-150')
|
||||
})
|
||||
|
||||
it('returns null neighbors when API also fails', async () => {
|
||||
mockSessionStorage(null)
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error('network error'))
|
||||
|
||||
const { useNavContext } = await import('../../lib/useNavContext')
|
||||
|
||||
function Harness() {
|
||||
const { getNeighbors } = useNavContext(999)
|
||||
const [n, setN] = React.useState(null)
|
||||
React.useEffect(() => {
|
||||
getNeighbors().then(setN)
|
||||
}, [getNeighbors])
|
||||
return n ? <div data-testid="result">{String(n.prevId)}|{String(n.nextId)}</div> : null
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
|
||||
expect(screen.getByTestId('result').textContent).toBe('null|null')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Keyboard Test ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Keyboard navigation', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('ArrowLeft key triggers navigate to previous artwork', async () => {
|
||||
// Test the keyboard event logic in isolation (the same logic used in ArtworkNavigator)
|
||||
const handler = vi.fn()
|
||||
const cleanup = []
|
||||
|
||||
function KeyTestHarness() {
|
||||
React.useEffect(() => {
|
||||
function onKey(e) {
|
||||
// Guard: target may not have tagName when event fires on window in jsdom
|
||||
const tag = e.target?.tagName?.toLowerCase?.() ?? ''
|
||||
if (['input', 'textarea', 'select'].includes(tag) || e.target?.isContentEditable) return
|
||||
if (e.key === 'ArrowLeft') handler('prev')
|
||||
if (e.key === 'ArrowRight') handler('next')
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
cleanup.push(() => window.removeEventListener('keydown', onKey))
|
||||
}, [])
|
||||
return <div />
|
||||
}
|
||||
|
||||
render(<KeyTestHarness />)
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
|
||||
expect(handler).toHaveBeenCalledWith('prev')
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
|
||||
expect(handler).toHaveBeenCalledWith('next')
|
||||
|
||||
cleanup.forEach(fn => fn())
|
||||
})
|
||||
|
||||
it('ESC key closes the viewer modal', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
|
||||
const onClose = vi.fn()
|
||||
const artwork = { id: 1, title: 'Test Art', thumbs: { lg: { url: '/img.jpg' } } }
|
||||
|
||||
render(
|
||||
<ArtworkViewer
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
artwork={artwork}
|
||||
presentLg={{ url: '/img.jpg' }}
|
||||
presentXl={null}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 4. Mobile Swipe Test ─────────────────────────────────────────────────────
|
||||
|
||||
describe('Mobile swipe navigation', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('left-to-right swipe fires prev navigation', () => {
|
||||
const handler = vi.fn()
|
||||
|
||||
function SwipeHarness() {
|
||||
const touchStartX = React.useRef(null)
|
||||
const touchStartY = React.useRef(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
function onStart(e) {
|
||||
touchStartX.current = e.touches[0].clientX
|
||||
touchStartY.current = e.touches[0].clientY
|
||||
}
|
||||
function onEnd(e) {
|
||||
if (touchStartX.current === null) return
|
||||
const dx = e.changedTouches[0].clientX - touchStartX.current
|
||||
const dy = e.changedTouches[0].clientY - touchStartY.current
|
||||
touchStartX.current = null
|
||||
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
|
||||
handler(dx > 0 ? 'prev' : 'next')
|
||||
}
|
||||
}
|
||||
window.addEventListener('touchstart', onStart, { passive: true })
|
||||
window.addEventListener('touchend', onEnd, { passive: true })
|
||||
return () => {
|
||||
window.removeEventListener('touchstart', onStart)
|
||||
window.removeEventListener('touchend', onEnd)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <div data-testid="swipe-target" />
|
||||
}
|
||||
|
||||
render(<SwipeHarness />)
|
||||
|
||||
// Simulate swipe right (prev)
|
||||
fireEvent(window, new TouchEvent('touchstart', {
|
||||
touches: [{ clientX: 200, clientY: 100 }],
|
||||
}))
|
||||
fireEvent(window, new TouchEvent('touchend', {
|
||||
changedTouches: [{ clientX: 260, clientY: 105 }],
|
||||
}))
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('prev')
|
||||
})
|
||||
|
||||
it('right-to-left swipe fires next navigation', () => {
|
||||
const handler = vi.fn()
|
||||
|
||||
function SwipeHarness() {
|
||||
const startX = React.useRef(null)
|
||||
const startY = React.useRef(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
function onStart(e) { startX.current = e.touches[0].clientX; startY.current = e.touches[0].clientY }
|
||||
function onEnd(e) {
|
||||
if (startX.current === null) return
|
||||
const dx = e.changedTouches[0].clientX - startX.current
|
||||
const dy = e.changedTouches[0].clientY - startY.current
|
||||
startX.current = null
|
||||
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) handler(dx > 0 ? 'prev' : 'next')
|
||||
}
|
||||
window.addEventListener('touchstart', onStart, { passive: true })
|
||||
window.addEventListener('touchend', onEnd, { passive: true })
|
||||
return () => { window.removeEventListener('touchstart', onStart); window.removeEventListener('touchend', onEnd) }
|
||||
}, [])
|
||||
|
||||
return <div />
|
||||
}
|
||||
|
||||
render(<SwipeHarness />)
|
||||
|
||||
fireEvent(window, new TouchEvent('touchstart', {
|
||||
touches: [{ clientX: 300, clientY: 100 }],
|
||||
}))
|
||||
fireEvent(window, new TouchEvent('touchend', {
|
||||
changedTouches: [{ clientX: 240, clientY: 103 }],
|
||||
}))
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('next')
|
||||
})
|
||||
|
||||
it('ignores swipe with large vertical component (scroll intent)', () => {
|
||||
const handler = vi.fn()
|
||||
|
||||
function SwipeHarness() {
|
||||
const startX = React.useRef(null)
|
||||
const startY = React.useRef(null)
|
||||
React.useEffect(() => {
|
||||
function onStart(e) { startX.current = e.touches[0].clientX; startY.current = e.touches[0].clientY }
|
||||
function onEnd(e) {
|
||||
if (startX.current === null) return
|
||||
const dx = e.changedTouches[0].clientX - startX.current
|
||||
const dy = e.changedTouches[0].clientY - startY.current
|
||||
startX.current = null
|
||||
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) handler('swipe')
|
||||
}
|
||||
window.addEventListener('touchstart', onStart, { passive: true })
|
||||
window.addEventListener('touchend', onEnd, { passive: true })
|
||||
return () => { window.removeEventListener('touchstart', onStart); window.removeEventListener('touchend', onEnd) }
|
||||
}, [])
|
||||
return <div />
|
||||
}
|
||||
|
||||
render(<SwipeHarness />)
|
||||
|
||||
// Diagonal swipe — large vertical component, should be ignored
|
||||
fireEvent(window, new TouchEvent('touchstart', {
|
||||
touches: [{ clientX: 100, clientY: 100 }],
|
||||
}))
|
||||
fireEvent(window, new TouchEvent('touchend', {
|
||||
changedTouches: [{ clientX: 200, clientY: 250 }],
|
||||
}))
|
||||
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 5. Modal Test ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ArtworkViewer modal', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('does not render when isOpen=false', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
||||
|
||||
render(
|
||||
<ArtworkViewer isOpen={false} onClose={() => {}} artwork={artwork} presentLg={null} presentXl={null} />
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders with title when isOpen=true', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
const artwork = { id: 1, title: 'My Artwork', thumbs: {} }
|
||||
|
||||
render(
|
||||
<ArtworkViewer
|
||||
isOpen={true}
|
||||
onClose={() => {}}
|
||||
artwork={artwork}
|
||||
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
|
||||
presentXl={null}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).not.toBeNull()
|
||||
expect(screen.getByAltText('My Artwork')).not.toBeNull()
|
||||
expect(screen.getByText('My Artwork')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('calls onClose when clicking the backdrop', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
const onClose = vi.fn()
|
||||
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
||||
|
||||
const { container } = render(
|
||||
<ArtworkViewer
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
artwork={artwork}
|
||||
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
|
||||
presentXl={null}
|
||||
/>
|
||||
)
|
||||
|
||||
// Click the backdrop (the dialog wrapper itself)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
fireEvent.click(dialog)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does NOT call onClose when clicking the image', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
const onClose = vi.fn()
|
||||
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
||||
|
||||
render(
|
||||
<ArtworkViewer
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
artwork={artwork}
|
||||
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
|
||||
presentXl={null}
|
||||
/>
|
||||
)
|
||||
|
||||
const img = screen.getByRole('img', { name: 'Art' })
|
||||
fireEvent.click(img)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onClose on ESC keydown', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
const onClose = vi.fn()
|
||||
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
||||
|
||||
render(
|
||||
<ArtworkViewer
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
artwork={artwork}
|
||||
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
|
||||
presentXl={null}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prefers presentXl over presentLg for image src', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
||||
|
||||
render(
|
||||
<ArtworkViewer
|
||||
isOpen={true}
|
||||
onClose={() => {}}
|
||||
artwork={artwork}
|
||||
presentLg={{ url: 'https://cdn/lg.jpg' }}
|
||||
presentXl={{ url: 'https://cdn/xl.jpg' }}
|
||||
/>
|
||||
)
|
||||
|
||||
const img = screen.getByRole('img', { name: 'Art' })
|
||||
expect(img.getAttribute('src')).toBe('https://cdn/xl.jpg')
|
||||
})
|
||||
})
|
||||
15
resources/js/entry-search.jsx
Normal file
15
resources/js/entry-search.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import SearchBar from './Search/SearchBar'
|
||||
|
||||
function mount() {
|
||||
const container = document.getElementById('topbar-search-root')
|
||||
if (!container) return
|
||||
createRoot(container).render(<SearchBar />)
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mount)
|
||||
} else {
|
||||
mount()
|
||||
}
|
||||
124
resources/js/lib/nav-context.js
Normal file
124
resources/js/lib/nav-context.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Nova Gallery Navigation Context
|
||||
*
|
||||
* Stores artwork list context in sessionStorage when a card is clicked,
|
||||
* so the artwork page can provide prev/next navigation without page reload.
|
||||
*
|
||||
* Context shape:
|
||||
* { source, key, ids: number[], index: number, page: string, ts: number }
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var STORAGE_KEY = 'nav_ctx';
|
||||
|
||||
function getPageContext() {
|
||||
var path = window.location.pathname;
|
||||
var search = window.location.search;
|
||||
|
||||
// /tag/{slug}
|
||||
var tagMatch = path.match(/^\/tag\/([^/]+)\/?$/);
|
||||
if (tagMatch) return { source: 'tag', key: 'tag:' + tagMatch[1] };
|
||||
|
||||
// /browse/{contentType}/{category...}
|
||||
var browseMatch = path.match(/^\/browse\/([^/]+)(?:\/(.+))?\/?$/);
|
||||
if (browseMatch) {
|
||||
var browsePart = browseMatch[1] + (browseMatch[2] ? '/' + browseMatch[2] : '');
|
||||
return { source: 'browse', key: 'browse:' + browsePart };
|
||||
}
|
||||
|
||||
// /search?q=...
|
||||
if (path === '/search' || path.startsWith('/search?')) {
|
||||
var q = new URLSearchParams(search).get('q') || '';
|
||||
return { source: 'search', key: 'search:' + q };
|
||||
}
|
||||
|
||||
// /@{username}
|
||||
var profileMatch = path.match(/^\/@([^/]+)\/?$/);
|
||||
if (profileMatch) return { source: 'profile', key: 'profile:' + profileMatch[1] };
|
||||
|
||||
// /members/...
|
||||
if (path.startsWith('/members')) return { source: 'members', key: 'members' };
|
||||
|
||||
// home
|
||||
if (path === '/' || path === '/home') return { source: 'home', key: 'home' };
|
||||
|
||||
return { source: 'page', key: 'page:' + path };
|
||||
}
|
||||
|
||||
function collectIds() {
|
||||
var cards = document.querySelectorAll('article[data-art-id]');
|
||||
var ids = [];
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
var raw = cards[i].getAttribute('data-art-id');
|
||||
var id = parseInt(raw, 10);
|
||||
if (id > 0 && !isNaN(id)) ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function saveContext(artId, ids, context) {
|
||||
var index = ids.indexOf(artId);
|
||||
if (index === -1) index = 0;
|
||||
var ctx = {
|
||||
source: context.source,
|
||||
key: context.key,
|
||||
ids: ids,
|
||||
index: index,
|
||||
page: window.location.href,
|
||||
ts: Date.now(),
|
||||
};
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(ctx));
|
||||
} catch (_) {
|
||||
// quota exceeded or private mode — silently skip
|
||||
}
|
||||
}
|
||||
|
||||
function findArticle(el) {
|
||||
var node = el;
|
||||
while (node && node !== document.body) {
|
||||
if (node.tagName === 'ARTICLE' && node.hasAttribute('data-art-id')) {
|
||||
return node;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function init() {
|
||||
// Only act on pages that have artwork cards (not the artwork detail page itself)
|
||||
var cards = document.querySelectorAll('article[data-art-id]');
|
||||
if (cards.length === 0) return;
|
||||
|
||||
// Don't inject on the artwork detail page (has #artwork-page mount)
|
||||
if (document.getElementById('artwork-page')) return;
|
||||
|
||||
var context = getPageContext();
|
||||
|
||||
document.addEventListener(
|
||||
'click',
|
||||
function (event) {
|
||||
var article = findArticle(event.target);
|
||||
if (!article) return;
|
||||
|
||||
// Make sure click was on or inside the card's <a> link
|
||||
var link = article.querySelector('a[href]');
|
||||
if (!link) return;
|
||||
|
||||
var artId = parseInt(article.getAttribute('data-art-id'), 10);
|
||||
if (!artId || isNaN(artId)) return;
|
||||
|
||||
var currentIds = collectIds();
|
||||
saveContext(artId, currentIds, context);
|
||||
},
|
||||
true // capture phase: store before navigation fires
|
||||
);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
43
resources/js/lib/useNavContext.js
Normal file
43
resources/js/lib/useNavContext.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* useNavContext
|
||||
*
|
||||
* Provides prev/next artwork IDs scoped to the same author via API.
|
||||
*/
|
||||
import { useCallback } from 'react';
|
||||
|
||||
// Module-level cache for API calls
|
||||
const fallbackCache = new Map();
|
||||
|
||||
async function fetchFallback(artworkId) {
|
||||
const key = String(artworkId);
|
||||
if (fallbackCache.has(key)) return fallbackCache.get(key);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/artworks/navigation/${artworkId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return { prevId: null, nextId: null, prevUrl: null, nextUrl: null };
|
||||
const data = await res.json();
|
||||
const result = {
|
||||
prevId: data.prev_id ?? null,
|
||||
nextId: data.next_id ?? null,
|
||||
prevUrl: data.prev_url ?? null,
|
||||
nextUrl: data.next_url ?? null,
|
||||
};
|
||||
fallbackCache.set(key, result);
|
||||
return result;
|
||||
} catch {
|
||||
return { prevId: null, nextId: null, prevUrl: null, nextUrl: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function useNavContext(currentArtworkId) {
|
||||
/**
|
||||
* Always resolve via API to guarantee same-user navigation.
|
||||
*/
|
||||
const getNeighbors = useCallback(async () => {
|
||||
return fetchFallback(currentArtworkId);
|
||||
}, [currentArtworkId]);
|
||||
|
||||
return { getNeighbors };
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
// - dropdown menus via [data-dropdown]
|
||||
// - mobile menu toggle via [data-mobile-toggle] + #mobileMenu
|
||||
|
||||
// Gallery navigation context: stores artwork list for prev/next on artwork page
|
||||
import './lib/nav-context.js';
|
||||
|
||||
(function () {
|
||||
function initBlurPreviewImages() {
|
||||
var selector = 'img[data-blur-preview]';
|
||||
|
||||
@@ -89,7 +89,9 @@
|
||||
data-present-xl='@json($presentXl)'
|
||||
data-present-sq='@json($presentSq)'
|
||||
data-cdn='@json(rtrim((string) config("cdn.files_url", "https://files.skinbase.org"), "/"))'
|
||||
data-canonical='@json($meta["canonical"])'>
|
||||
data-canonical='@json($meta["canonical"])'
|
||||
data-comments='@json($comments)'
|
||||
data-is-authenticated='@json(auth()->check())'>
|
||||
</div>
|
||||
|
||||
@vite(['resources/js/Pages/ArtworkPage.jsx'])
|
||||
|
||||
@@ -118,7 +118,11 @@
|
||||
}
|
||||
@endphp
|
||||
|
||||
<article class="nova-card gallery-item artwork" itemscope itemtype="https://schema.org/ImageObject">
|
||||
<article class="nova-card gallery-item artwork" itemscope itemtype="https://schema.org/ImageObject"
|
||||
data-art-id="{{ $art->id ?? '' }}"
|
||||
data-art-url="{{ $cardUrl }}"
|
||||
data-art-title="{{ e($title) }}"
|
||||
data-art-img="{{ $imgSrc }}">
|
||||
<meta itemprop="name" content="{{ $title }}">
|
||||
<meta itemprop="contentUrl" content="{{ $contentUrl }}">
|
||||
<meta itemprop="creator" content="{{ $author }}">
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
</div>
|
||||
|
||||
<section class="px-6 pb-10 pt-8 md:px-10" data-nova-gallery data-gallery-type="{{ $gallery_type ?? 'browse' }}">
|
||||
<div class="{{ $gridV2 ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 force-5' }}" data-gallery-grid>
|
||||
<div class="{{ $gridV2 ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 force-5' }}" data-gallery-grid>
|
||||
@forelse ($artworks as $art)
|
||||
<x-artwork-card
|
||||
:art="$art"
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
|
||||
<link rel="shortcut icon" href="/favicon.ico">
|
||||
|
||||
@vite(['resources/css/app.css','resources/css/nova-grid.css','resources/scss/nova.scss','resources/js/nova.js'])
|
||||
@vite(['resources/css/app.css','resources/css/nova-grid.css','resources/scss/nova.scss','resources/js/nova.js','resources/js/entry-search.jsx'])
|
||||
<style>
|
||||
/* Card enter animation */
|
||||
.nova-card-enter { opacity: 0; transform: translateY(10px) scale(0.995); }
|
||||
|
||||
@@ -70,18 +70,8 @@
|
||||
|
||||
<!-- Search -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="w-full max-w-lg relative">
|
||||
<input
|
||||
class="w-full h-10 rounded-lg bg-black/20 border border-sb-line pl-4 pr-12 text-sm text-white placeholder:text-sb-muted/80 outline-none focus:border-sb-blue/60"
|
||||
placeholder="Search tags, artworks, artists..." />
|
||||
<button
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 w-9 h-9 rounded-md hover:bg-white/5 text-sb-muted hover:text-white">
|
||||
<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M20 20l-3.5-3.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="w-full max-w-lg">
|
||||
<div id="topbar-search-root"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,50 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php($gridV2 = request()->query('grid') === 'v2')
|
||||
|
||||
@php
|
||||
$uname = $user->username ?? $user->name ?? 'Unknown';
|
||||
$displayName = $user->name ?? $uname;
|
||||
$avatarUrl = \App\Support\AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 128);
|
||||
|
||||
$genderMap = [
|
||||
'M' => ['label' => 'Male', 'icon' => 'fa-mars', 'color' => 'text-blue-400'],
|
||||
'F' => ['label' => 'Female', 'icon' => 'fa-venus', 'color' => 'text-pink-400'],
|
||||
'X' => ['label' => 'N/A', 'icon' => 'fa-question', 'color' => 'text-gray-400'],
|
||||
];
|
||||
$genderCode = strtoupper((string) ($profile?->gender ?? 'X'));
|
||||
$gender = $genderMap[$genderCode] ?? $genderMap['X'];
|
||||
|
||||
$birthdate = null;
|
||||
if ($profile?->birthdate) {
|
||||
try {
|
||||
$bd = \Carbon\Carbon::parse($profile->birthdate);
|
||||
if ($bd->year > 1900) {
|
||||
$birthdate = $bd->format('F d, Y');
|
||||
}
|
||||
} catch (\Throwable) {}
|
||||
}
|
||||
|
||||
$website = $profile?->website ?? null;
|
||||
if ($website && !preg_match('#^https?://#i', $website)) {
|
||||
$website = 'https://' . $website;
|
||||
}
|
||||
|
||||
$about = $profile?->about ?? null;
|
||||
|
||||
$lastVisit = null;
|
||||
if ($user->last_visit_at) {
|
||||
try { $lastVisit = \Carbon\Carbon::parse($user->last_visit_at); } catch (\Throwable) {}
|
||||
}
|
||||
|
||||
$socialIcons = [
|
||||
'twitter' => ['icon' => 'fa-brands fa-x-twitter', 'label' => 'X / Twitter'],
|
||||
'deviantart' => ['icon' => 'fa-brands fa-deviantart', 'label' => 'DeviantArt'],
|
||||
'instagram' => ['icon' => 'fa-brands fa-instagram', 'label' => 'Instagram'],
|
||||
'behance' => ['icon' => 'fa-brands fa-behance', 'label' => 'Behance'],
|
||||
'artstation' => ['icon' => 'fa-solid fa-palette', 'label' => 'ArtStation'],
|
||||
'youtube' => ['icon' => 'fa-brands fa-youtube', 'label' => 'YouTube'],
|
||||
'website' => ['icon' => 'fa-solid fa-link', 'label' => 'Website'],
|
||||
];
|
||||
|
||||
$seoPage = max(1, (int) request()->query('page', 1));
|
||||
$seoBase = url()->current();
|
||||
$seoQ = request()->query(); unset($seoQ['page']);
|
||||
@@ -10,8 +52,7 @@
|
||||
? '?' . http_build_query(array_merge($seoQ, ['page' => $p]))
|
||||
: (count($seoQ) ? '?' . http_build_query($seoQ) : ''));
|
||||
$seoPrev = $seoPage > 1 ? $seoUrl($seoPage - 1) : null;
|
||||
$seoNext = (isset($artworks) && method_exists($artworks, 'nextPageUrl'))
|
||||
? $artworks->nextPageUrl() : null;
|
||||
$seoNext = (isset($artworks) && method_exists($artworks, 'nextPageUrl')) ? $artworks->nextPageUrl() : null;
|
||||
@endphp
|
||||
|
||||
@push('head')
|
||||
@@ -19,47 +60,626 @@
|
||||
@if($seoPrev)<link rel="prev" href="{{ $seoPrev }}">@endif
|
||||
@if($seoNext)<link rel="next" href="{{ $seoNext }}">@endif
|
||||
<meta name="robots" content="index,follow">
|
||||
<meta property="og:title" content="Profile: {{ e($uname) }} – Skinbase.org">
|
||||
<meta property="og:image" content="{{ $avatarUrl }}">
|
||||
<meta property="og:url" content="{{ $page_canonical ?? url()->current() }}">
|
||||
<style>
|
||||
.profile-hero-bg {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(15,23,36,0.98) 0%,
|
||||
rgba(21,30,46,0.95) 50%,
|
||||
rgba(9,16,26,0.98) 100%);
|
||||
position: relative;
|
||||
}
|
||||
.profile-hero-bg::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 50%, rgba(77,163,255,.12), transparent 60%),
|
||||
radial-gradient(ellipse at 80% 20%, rgba(224,122,33,.08), transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.nova-panel {
|
||||
background: var(--panel-dark);
|
||||
border: 1px solid var(--sb-line);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.nova-panel-header {
|
||||
padding: 0.65rem 1rem;
|
||||
border-bottom: 1px solid var(--sb-line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
color: var(--sb-text);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.nova-panel-body { padding: 1rem; }
|
||||
.stat-item { text-align: center; padding: 0.5rem 0.75rem; }
|
||||
.stat-item .stat-value {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stat-item .stat-label {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--sb-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.profile-table td:first-child {
|
||||
color: var(--sb-muted);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
padding-right: 0.75rem;
|
||||
padding-top: 0.45rem;
|
||||
padding-bottom: 0.45rem;
|
||||
}
|
||||
.profile-table td:last-child {
|
||||
color: var(--sb-text);
|
||||
font-size: 0.82rem;
|
||||
text-align: right;
|
||||
}
|
||||
.profile-table tr {
|
||||
border-bottom: 1px solid rgba(42,42,51,0.5);
|
||||
}
|
||||
.profile-table tr:last-child { border-bottom: none; }
|
||||
.profile-table td { vertical-align: middle; }
|
||||
.comment-avatar {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 50%; object-fit: cover; flex-shrink: 0;
|
||||
}
|
||||
.follower-avatar {
|
||||
width: 34px; height: 34px;
|
||||
border-radius: 50%; object-fit: cover;
|
||||
border: 1px solid var(--sb-line);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.follower-avatar:hover { opacity: 0.85; }
|
||||
.fav-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
.fav-grid a img {
|
||||
width: 100%; aspect-ratio: 1; object-fit: cover;
|
||||
border-radius: 4px; transition: opacity 0.2s;
|
||||
display: block;
|
||||
}
|
||||
.fav-grid a:hover img { opacity: 0.82; }
|
||||
.follow-btn { transition: all 0.2s ease; }
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="effect2 page-header-wrap">
|
||||
<header class="page-heading">
|
||||
<h1 class="page-header">Profile: {{ $user->uname }}</h1>
|
||||
<p>{{ $user->name ?? '' }}</p>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="panel panel-default effect2">
|
||||
<div class="panel-heading"><strong>Newest Artworks</strong></div>
|
||||
<div class="panel-body">
|
||||
<div class="{{ $gridV2 ? 'gallery' : 'gallery-grid' }}" data-nova-gallery data-gallery-type="profile" data-gallery-grid>
|
||||
@foreach($artworks as $art)
|
||||
<x-artwork-card :art="$art" />
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="hidden" data-gallery-skeleton-template aria-hidden="true">
|
||||
<x-skeleton.artwork-card />
|
||||
</div>
|
||||
<div class="hidden mt-8" data-gallery-skeleton></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{-- ═══════════════════════════════════════════════════════════
|
||||
PROFILE HERO
|
||||
═══════════════════════════════════════════════════════════ --}}
|
||||
<div class="profile-hero-bg border-b border-[--sb-line]">
|
||||
<div class="relative z-10 max-w-screen-xl mx-auto px-4 py-8">
|
||||
<div class="flex flex-col sm:flex-row items-center sm:items-end gap-5">
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="panel panel-default effect2">
|
||||
<div class="panel-heading"><strong>User</strong></div>
|
||||
<div class="panel-body">
|
||||
<img src="{{ \App\Support\AvatarUrl::forUser((int) $user->user_id, null, 128) }}" class="img-responsive" style="max-width:120px;" alt="{{ $user->uname }}">
|
||||
<h3>{{ $user->uname }}</h3>
|
||||
<p>{{ $user->about_me ?? '' }}</p>
|
||||
</div>
|
||||
{{-- Avatar --}}
|
||||
<div class="shrink-0">
|
||||
<img src="{{ $avatarUrl }}"
|
||||
alt="{{ e($uname) }}"
|
||||
class="w-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover border-4 border-[--sb-line] shadow-lg">
|
||||
</div>
|
||||
|
||||
{{-- Name + meta --}}
|
||||
<div class="flex-1 text-center sm:text-left min-w-0">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-white leading-tight truncate">
|
||||
{{ e($uname) }}
|
||||
</h1>
|
||||
@if($displayName && $displayName !== $uname)
|
||||
<p class="text-[--sb-muted] text-sm mt-0.5">{{ e($displayName) }}</p>
|
||||
@endif
|
||||
@if($countryName)
|
||||
<p class="text-[--sb-muted] text-sm mt-1 flex items-center justify-center sm:justify-start gap-1.5">
|
||||
@if($profile?->country_code)
|
||||
<img src="/gfx/flags/shiny/24/{{ rawurlencode($profile->country_code) }}.png"
|
||||
alt="{{ e($countryName) }}"
|
||||
class="w-5 h-auto rounded-sm inline-block"
|
||||
onerror="this.style.display='none'">
|
||||
@endif
|
||||
{{ e($countryName) }}
|
||||
</p>
|
||||
@endif
|
||||
@if($lastVisit)
|
||||
<p class="text-[--sb-muted] text-xs mt-1">
|
||||
<i class="fa-solid fa-clock fa-fw mr-1"></i>
|
||||
Last seen {{ $lastVisit->diffForHumans() }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Action buttons --}}
|
||||
<div class="shrink-0 flex flex-col gap-2 items-center sm:items-end">
|
||||
@if(!$isOwner)
|
||||
@auth
|
||||
<div x-data="{
|
||||
following: {{ $viewerIsFollowing ? 'true' : 'false' }},
|
||||
count: {{ (int) $followerCount }},
|
||||
loading: false,
|
||||
async toggle() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('{{ route('profile.follow', ['username' => strtolower((string)$uname)]) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok) { this.following = d.following; this.count = d.follower_count; }
|
||||
} catch(e) {}
|
||||
this.loading = false;
|
||||
}
|
||||
}">
|
||||
<button @click="toggle" :disabled="loading" class="follow-btn inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border transition-all"
|
||||
:class="following
|
||||
? 'bg-green-500/10 border-green-500/40 text-green-400 hover:bg-red-500/10 hover:border-red-500/40 hover:text-red-400'
|
||||
: 'bg-[--sb-blue]/10 border-[--sb-blue]/40 text-[--sb-blue] hover:bg-[--sb-blue]/20'">
|
||||
<i class="fa-solid fa-fw"
|
||||
:class="loading ? 'fa-circle-notch fa-spin' : (following ? 'fa-user-check' : 'fa-user-plus')"></i>
|
||||
<span x-text="following ? 'Following' : 'Follow'"></span>
|
||||
<span class="text-xs opacity-60" x-text="'(' + count + ')'"></span>
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<a href="{{ route('login') }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-[--sb-blue]/40 text-[--sb-blue] hover:bg-[--sb-blue]/10 transition-all">
|
||||
<i class="fa-solid fa-user-plus fa-fw"></i> Follow
|
||||
<span class="text-xs opacity-60">({{ $followerCount }})</span>
|
||||
</a>
|
||||
@endauth
|
||||
@else
|
||||
<a href="{{ route('dashboard.profile') }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-[--sb-line] text-[--sb-text] hover:bg-white/5 transition-all">
|
||||
<i class="fa-solid fa-pen fa-fw"></i> Edit Profile
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ═══════════════════════════════════════════════════════════
|
||||
STATS STRIP
|
||||
═══════════════════════════════════════════════════════════ --}}
|
||||
<div class="bg-[--sb-panel2] border-b border-[--sb-line]">
|
||||
<div class="max-w-screen-xl mx-auto px-4">
|
||||
<div class="flex divide-x divide-[--sb-line] overflow-x-auto">
|
||||
@foreach([
|
||||
['value' => number_format($stats->uploads ?? 0), 'label' => 'Uploads', 'icon' => 'fa-cloud-arrow-up'],
|
||||
['value' => number_format($stats->downloads ?? 0), 'label' => 'Downloads', 'icon' => 'fa-download'],
|
||||
['value' => number_format($stats->profile_views ?? 0), 'label' => 'Profile Views', 'icon' => 'fa-eye'],
|
||||
['value' => number_format($followerCount), 'label' => 'Followers', 'icon' => 'fa-users'],
|
||||
['value' => number_format($stats->awards ?? 0), 'label' => 'Awards', 'icon' => 'fa-trophy'],
|
||||
] as $si)
|
||||
<div class="stat-item flex-1 py-3">
|
||||
<div class="stat-value">{{ $si['value'] }}</div>
|
||||
<div class="stat-label">
|
||||
<i class="fa-solid {{ $si['icon'] }} fa-fw mr-0.5"></i>{{ $si['label'] }}
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ═══════════════════════════════════════════════════════════
|
||||
MAIN CONTENT
|
||||
═══════════════════════════════════════════════════════════ --}}
|
||||
<div class="max-w-screen-xl mx-auto px-4 py-6">
|
||||
<div class="flex flex-col lg:flex-row gap-5">
|
||||
|
||||
{{-- ─── LEFT COLUMN (artworks) ─── --}}
|
||||
<div class="flex-1 min-w-0 space-y-5">
|
||||
|
||||
{{-- Featured Artworks --}}
|
||||
@if(isset($featuredArtworks) && $featuredArtworks->isNotEmpty())
|
||||
<div class="nova-panel">
|
||||
<div class="nova-panel-header">
|
||||
<i class="fa-solid fa-star text-yellow-400 fa-fw"></i>
|
||||
Featured Artworks
|
||||
</div>
|
||||
<div class="nova-panel-body">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
@php $feat = $featuredArtworks->first() @endphp
|
||||
{{-- Main featured --}}
|
||||
<a href="/art/{{ $feat->id }}/{{ \Illuminate\Support\Str::slug($feat->name) }}"
|
||||
class="flex-1 group block min-w-0">
|
||||
<div class="overflow-hidden rounded-lg bg-black">
|
||||
<img src="{{ $feat->thumb }}"
|
||||
alt="{{ e($feat->name) }}"
|
||||
class="w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
style="aspect-ratio:4/3;">
|
||||
</div>
|
||||
<h4 class="mt-2 text-sm font-medium text-white truncate">{{ e($feat->name) }}</h4>
|
||||
@if($feat->label)
|
||||
<p class="text-xs text-[--sb-muted]">{{ e($feat->label) }}</p>
|
||||
@endif
|
||||
@if($feat->featured_at)
|
||||
<p class="text-xs text-[--sb-muted] mt-0.5">
|
||||
<i class="fa-solid fa-calendar fa-fw"></i>
|
||||
Featured {{ \Carbon\Carbon::parse($feat->featured_at)->format('d M, Y') }}
|
||||
</p>
|
||||
@endif
|
||||
</a>
|
||||
{{-- Side featured (2nd & 3rd) --}}
|
||||
@if($featuredArtworks->count() > 1)
|
||||
<div class="md:w-44 space-y-2">
|
||||
@foreach($featuredArtworks->slice(1) as $sideArt)
|
||||
<a href="/art/{{ $sideArt->id }}/{{ \Illuminate\Support\Str::slug($sideArt->name) }}"
|
||||
class="block group">
|
||||
<div class="overflow-hidden rounded-md bg-black">
|
||||
<img src="{{ $sideArt->thumb }}"
|
||||
alt="{{ e($sideArt->name) }}"
|
||||
class="w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
style="aspect-ratio:16/9;">
|
||||
</div>
|
||||
<p class="text-xs text-[--sb-muted] mt-1 truncate">{{ e($sideArt->name) }}</p>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Newest Artworks --}}
|
||||
<div class="nova-panel">
|
||||
<div class="nova-panel-header">
|
||||
<i class="fa-solid fa-images fa-fw text-[--sb-blue]"></i>
|
||||
Newest Artworks
|
||||
<a href="/gallery/{{ $user->id }}/{{ \Illuminate\Support\Str::slug($uname) }}"
|
||||
class="ml-auto text-xs text-[--sb-blue] hover:underline normal-case tracking-normal font-normal">
|
||||
View Gallery <i class="fa-solid fa-arrow-right fa-fw"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="nova-panel-body">
|
||||
@if(isset($artworks) && !$artworks->isEmpty())
|
||||
<div class="gallery-grid"
|
||||
data-nova-gallery
|
||||
data-gallery-type="profile"
|
||||
data-gallery-grid
|
||||
data-profile-id="{{ $user->id }}">
|
||||
@foreach($artworks as $art)
|
||||
<x-artwork-card :art="$art" />
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="hidden" data-gallery-skeleton-template aria-hidden="true">
|
||||
<x-skeleton.artwork-card />
|
||||
</div>
|
||||
<div class="hidden mt-6" data-gallery-skeleton></div>
|
||||
@else
|
||||
<p class="text-[--sb-muted] text-sm text-center py-8">
|
||||
<i class="fa-solid fa-image fa-2x mb-3 block opacity-20"></i>
|
||||
No artworks yet.
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Favourites --}}
|
||||
@if(isset($favourites) && $favourites->isNotEmpty())
|
||||
<div class="nova-panel">
|
||||
<div class="nova-panel-header">
|
||||
<i class="fa-solid fa-heart fa-fw text-pink-400"></i>
|
||||
Favourites
|
||||
</div>
|
||||
<div class="nova-panel-body">
|
||||
<div class="fav-grid">
|
||||
@foreach($favourites as $fav)
|
||||
<a href="/art/{{ $fav->id }}/{{ \Illuminate\Support\Str::slug($fav->name) }}"
|
||||
title="{{ e($fav->name) }}">
|
||||
<img src="{{ $fav->thumb }}" alt="{{ e($fav->name) }}" loading="lazy">
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>{{-- end left --}}
|
||||
|
||||
{{-- ─── RIGHT SIDEBAR ─── --}}
|
||||
<div class="lg:w-80 xl:w-96 shrink-0 space-y-4">
|
||||
|
||||
{{-- Profile Info --}}
|
||||
<div class="nova-panel">
|
||||
<div class="nova-panel-header">
|
||||
<i class="fa-solid fa-id-card fa-fw text-[--sb-blue]"></i>
|
||||
Profile
|
||||
</div>
|
||||
<div class="nova-panel-body">
|
||||
<table class="profile-table w-full">
|
||||
<tr>
|
||||
<td>Username</td>
|
||||
<td>{{ e($uname) }}</td>
|
||||
</tr>
|
||||
@if($displayName && $displayName !== $uname)
|
||||
<tr><td>Real Name</td><td>{{ e($displayName) }}</td></tr>
|
||||
@endif
|
||||
<tr>
|
||||
<td>Gender</td>
|
||||
<td>
|
||||
<i class="fa-solid {{ $gender['icon'] }} fa-fw {{ $gender['color'] }}"></i>
|
||||
{{ $gender['label'] }}
|
||||
</td>
|
||||
</tr>
|
||||
@if($birthdate)
|
||||
<tr><td>Birthday</td><td>{{ $birthdate }}</td></tr>
|
||||
@endif
|
||||
@if($countryName)
|
||||
<tr>
|
||||
<td>Country</td>
|
||||
<td class="flex items-center justify-end gap-1.5">
|
||||
@if($profile?->country_code)
|
||||
<img src="/gfx/flags/shiny/24/{{ rawurlencode($profile->country_code) }}.png"
|
||||
alt="{{ e($countryName) }}"
|
||||
class="w-4 h-auto rounded-sm"
|
||||
onerror="this.style.display='none'">
|
||||
@endif
|
||||
{{ e($countryName) }}
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
@if($website)
|
||||
<tr>
|
||||
<td>Website</td>
|
||||
<td>
|
||||
<a href="{{ e($website) }}" rel="nofollow noopener" target="_blank"
|
||||
class="text-[--sb-blue] hover:underline text-xs inline-flex items-center gap-1">
|
||||
<i class="fa-solid fa-link fa-fw"></i>
|
||||
{{ e(parse_url($website, PHP_URL_HOST) ?? $website) }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
@if($lastVisit)
|
||||
<tr>
|
||||
<td>Last Activity</td>
|
||||
<td class="text-[11px]">
|
||||
{{ $lastVisit->format('d.M.Y') }}
|
||||
<i class="fa-solid fa-clock fa-fw ml-1 opacity-60"></i>
|
||||
{{ $lastVisit->format('H:i') }}
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr>
|
||||
<td>Member since</td>
|
||||
<td>{{ $user->created_at ? $user->created_at->format('M Y') : 'N/A' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- About Me --}}
|
||||
@if($about)
|
||||
<div class="nova-panel" x-data="{ expanded: false }">
|
||||
<div class="nova-panel-header">
|
||||
<i class="fa-solid fa-quote-left fa-fw text-purple-400"></i>
|
||||
About Me
|
||||
</div>
|
||||
<div class="nova-panel-body">
|
||||
<div class="text-sm text-[--sb-text] leading-relaxed"
|
||||
:class="expanded ? '' : 'line-clamp-6'">
|
||||
{!! nl2br(e($about)) !!}
|
||||
</div>
|
||||
@if(strlen($about) > 300)
|
||||
<button @click="expanded = !expanded"
|
||||
class="mt-2 text-xs text-[--sb-blue] hover:underline">
|
||||
<span x-text="expanded ? '↑ Show less' : '↓ Read more'"></span>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Statistics --}}
|
||||
@if($stats)
|
||||
<div class="nova-panel">
|
||||
<div class="nova-panel-header">
|
||||
<i class="fa-solid fa-chart-bar fa-fw text-green-400"></i>
|
||||
Statistics
|
||||
</div>
|
||||
<div class="nova-panel-body p-0">
|
||||
<table class="profile-table w-full">
|
||||
@foreach([
|
||||
['Profile Views', number_format($stats->profile_views ?? 0), null],
|
||||
['Uploads', number_format($stats->uploads ?? 0), null],
|
||||
['Downloads', number_format($stats->downloads ?? 0), null],
|
||||
['Page Views', number_format($stats->pageviews ?? 0), null],
|
||||
['Featured Works',number_format($stats->awards ?? 0), 'fa-star text-yellow-400'],
|
||||
] as [$label, $value, $iconClass])
|
||||
<tr>
|
||||
<td class="pl-4">{{ $label }}</td>
|
||||
<td class="pr-4">
|
||||
{{ $value }}
|
||||
@if($iconClass)<i class="fa-solid {{ $iconClass }} text-xs ml-1"></i>@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Social Links --}}
|
||||
@if(isset($socialLinks) && $socialLinks->isNotEmpty())
|
||||
<div class="nova-panel">
|
||||
<div class="nova-panel-header">
|
||||
<i class="fa-solid fa-share-nodes fa-fw text-[--sb-blue]"></i>
|
||||
Social Links
|
||||
</div>
|
||||
<div class="nova-panel-body flex flex-wrap gap-2">
|
||||
@foreach($socialLinks as $platform => $link)
|
||||
@php
|
||||
$si = $socialIcons[$platform] ?? ['icon' => 'fa-solid fa-link', 'label' => ucfirst($platform)];
|
||||
$href = str_starts_with($link->url, 'http') ? $link->url : ('https://' . $link->url);
|
||||
@endphp
|
||||
<a href="{{ e($href) }}" rel="nofollow noopener" target="_blank"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs border border-[--sb-line] text-[--sb-text] hover:bg-white/5 hover:border-[--sb-blue]/40 transition-all">
|
||||
<i class="{{ $si['icon'] }} fa-fw"></i>
|
||||
{{ $si['label'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Recent Followers --}}
|
||||
@if(isset($recentFollowers) && $recentFollowers->isNotEmpty())
|
||||
<div class="nova-panel">
|
||||
<div class="nova-panel-header">
|
||||
<i class="fa-solid fa-users fa-fw text-[--sb-blue]"></i>
|
||||
Followers
|
||||
<span class="ml-1 px-1.5 py-0.5 rounded text-xs bg-white/5 text-[--sb-muted]">
|
||||
{{ number_format($followerCount) }}
|
||||
</span>
|
||||
<a href="/following/{{ $user->id }}/{{ \Illuminate\Support\Str::slug($uname) }}"
|
||||
class="ml-auto text-xs text-[--sb-blue] hover:underline normal-case tracking-normal font-normal">
|
||||
All
|
||||
</a>
|
||||
</div>
|
||||
<div class="nova-panel-body">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach($recentFollowers as $follower)
|
||||
<a href="{{ $follower->profile_url }}" title="{{ e($follower->uname) }}">
|
||||
<img src="{{ $follower->avatar_url }}"
|
||||
alt="{{ e($follower->uname) }}"
|
||||
class="follower-avatar"
|
||||
onerror="this.src='{{ \App\Support\AvatarUrl::default() }}'">
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@elseif($followerCount > 0)
|
||||
<div class="nova-panel">
|
||||
<div class="nova-panel-header">
|
||||
<i class="fa-solid fa-users fa-fw text-[--sb-blue]"></i>
|
||||
Followers
|
||||
<span class="ml-1 px-1.5 py-0.5 rounded text-xs bg-white/5 text-[--sb-muted]">
|
||||
{{ number_format($followerCount) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Profile Comments --}}
|
||||
<div class="nova-panel">
|
||||
<div class="nova-panel-header">
|
||||
<i class="fa-solid fa-comments fa-fw text-orange-400"></i>
|
||||
Comments
|
||||
@if(isset($profileComments) && $profileComments->isNotEmpty())
|
||||
<span class="ml-1 px-1.5 py-0.5 rounded text-xs bg-white/5 text-[--sb-muted]">
|
||||
{{ $profileComments->count() }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="nova-panel-body">
|
||||
@if(!isset($profileComments) || $profileComments->isEmpty())
|
||||
<p class="text-[--sb-muted] text-xs text-center py-3">No comments yet.</p>
|
||||
@else
|
||||
<div class="space-y-4">
|
||||
@foreach($profileComments as $comment)
|
||||
<div class="flex gap-3">
|
||||
<a href="{{ $comment->author_profile_url }}" class="shrink-0">
|
||||
<img src="{{ $comment->author_avatar }}"
|
||||
alt="{{ e($comment->author_name) }}"
|
||||
class="comment-avatar"
|
||||
onerror="this.src='{{ \App\Support\AvatarUrl::default() }}'">
|
||||
</a>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<a href="{{ $comment->author_profile_url }}"
|
||||
class="text-xs font-semibold text-[--sb-text] hover:text-[--sb-blue] transition-colors">
|
||||
{{ e($comment->author_name) }}
|
||||
</a>
|
||||
<span class="text-[--sb-muted] text-[10px] ml-auto whitespace-nowrap">
|
||||
{{ \Carbon\Carbon::parse($comment->created_at)->diffForHumans() }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-[--sb-text] leading-relaxed break-words">
|
||||
{!! nl2br(e($comment->body)) !!}
|
||||
</p>
|
||||
@if(!empty($comment->author_signature))
|
||||
<p class="text-[--sb-muted] text-[10px] mt-1 italic border-t border-[--sb-line] pt-1 opacity-70">
|
||||
{!! nl2br(e($comment->author_signature)) !!}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Write Comment --}}
|
||||
@auth
|
||||
@if(auth()->id() !== $user->id)
|
||||
<div class="nova-panel">
|
||||
<div class="nova-panel-header">
|
||||
<i class="fa-solid fa-pen fa-fw text-[--sb-blue]"></i>
|
||||
Write a Comment
|
||||
</div>
|
||||
<div class="nova-panel-body">
|
||||
@if(session('status') === 'Comment posted!')
|
||||
<div class="mb-3 px-3 py-2 rounded-lg bg-green-500/10 border border-green-500/30 text-green-400 text-xs">
|
||||
<i class="fa-solid fa-check fa-fw"></i> Comment posted!
|
||||
</div>
|
||||
@endif
|
||||
<form method="POST"
|
||||
action="{{ route('profile.comment', ['username' => strtolower((string)$uname)]) }}">
|
||||
@csrf
|
||||
<textarea name="body" rows="4" required minlength="2" maxlength="2000"
|
||||
placeholder="Write a comment for {{ e($uname) }}..."
|
||||
class="w-full bg-[--sb-bg] border border-[--sb-line] rounded-lg px-3 py-2 text-sm text-[--sb-text] placeholder:text-[--sb-muted]/60 resize-none focus:outline-none focus:border-[--sb-blue]/50 transition-colors"
|
||||
>{{ old('body') }}</textarea>
|
||||
@error('body')
|
||||
<p class="mt-1 text-xs text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
<div class="mt-2 text-right">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-[--sb-blue]/90 hover:bg-[--sb-blue] text-white transition-colors">
|
||||
<i class="fa-solid fa-paper-plane fa-fw"></i>
|
||||
Post Comment
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="nova-panel">
|
||||
<div class="nova-panel-body text-center py-4">
|
||||
<p class="text-[--sb-muted] text-sm">
|
||||
<a href="{{ route('login') }}" class="text-[--sb-blue] hover:underline">Log in</a>
|
||||
to leave a comment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@endauth
|
||||
|
||||
</div>{{-- end right sidebar --}}
|
||||
|
||||
</div>{{-- end flex --}}
|
||||
</div>{{-- end container --}}
|
||||
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
|
||||
78
resources/views/search/index.blade.php
Normal file
78
resources/views/search/index.blade.php
Normal file
@@ -0,0 +1,78 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@push('head')
|
||||
<meta name="robots" content="noindex,follow">
|
||||
<meta name="description" content="Search Skinbase artworks, photography, wallpapers and skins.">
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="px-6 py-8 md:px-10" id="search-page" data-q="{{ $q ?? '' }}">
|
||||
|
||||
{{-- Search header --}}
|
||||
<div class="mb-8 max-w-2xl">
|
||||
<h1 class="text-2xl font-bold text-white mb-2">Search</h1>
|
||||
<form action="/search" method="GET" class="relative" role="search">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value="{{ $q ?? '' }}"
|
||||
placeholder="Search artworks, artists, tags…"
|
||||
autofocus
|
||||
class="w-full bg-white/[0.05] border border-white/10 rounded-xl py-3 pl-4 pr-12 text-white placeholder-neutral-500 outline-none focus:border-sky-500 transition-colors"
|
||||
>
|
||||
<button type="submit" class="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-sky-400 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if(isset($q) && $q !== '')
|
||||
{{-- Sort + filter bar --}}
|
||||
<div class="flex flex-wrap items-center gap-3 mb-6">
|
||||
<span class="text-sm text-neutral-400">Sort by:</span>
|
||||
@foreach(['latest' => 'Newest', 'popular' => 'Most viewed', 'likes' => 'Most liked', 'downloads' => 'Most downloaded'] as $key => $label)
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => $key]) }}"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors
|
||||
{{ ($sort ?? 'latest') === $key ? 'bg-sky-500 text-white' : 'bg-white/5 text-neutral-400 hover:bg-white/10 hover:text-white' }}">
|
||||
{{ $label }}
|
||||
</a>
|
||||
@endforeach
|
||||
|
||||
@if($artworks->total() > 0)
|
||||
<span class="ml-auto text-sm text-neutral-500">
|
||||
{{ number_format($artworks->total()) }} {{ Str::plural('result', $artworks->total()) }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Results grid --}}
|
||||
@if($artworks->isEmpty())
|
||||
<div class="rounded-xl bg-white/[0.03] border border-white/[0.06] p-14 text-center">
|
||||
<p class="text-neutral-400 text-lg mb-2">No results for <span class="text-white">"{{ $q }}"</span></p>
|
||||
<p class="text-sm text-neutral-500">Try a different keyword or browse by <a href="/browse" class="text-sky-400 hover:underline">category</a>.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
|
||||
@foreach($artworks as $artwork)
|
||||
<x-artwork-card :art="$artwork" />
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-10">
|
||||
{{ $artworks->appends(request()->query())->links('pagination::tailwind') }}
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
{{-- No query: show popular --}}
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<span class="text-sm font-semibold text-white/70 uppercase tracking-wide">Popular right now</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
|
||||
@foreach($popular as $artwork)
|
||||
<x-artwork-card :art="$artwork" />
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,37 +1,83 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@push('head')
|
||||
<link rel="canonical" href="{{ $page_canonical }}">
|
||||
<meta name="robots" content="{{ $page_robots ?? 'index,follow' }}">
|
||||
@if(!empty($ogImage))
|
||||
<meta property="og:image" content="{{ $ogImage }}">
|
||||
<meta property="og:image:alt" content="{{ $tag->name }} artworks on Skinbase">
|
||||
@endif
|
||||
<meta property="og:title" content="{{ $page_title }}">
|
||||
<meta property="og:description" content="{{ $page_meta_description }}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ $page_canonical }}">
|
||||
<script type="application/ld+json">{!! json_encode([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'CollectionPage',
|
||||
'name' => 'Artworks tagged "' . $tag->name . '"',
|
||||
'description' => $page_meta_description,
|
||||
'url' => $page_canonical,
|
||||
'image' => $ogImage,
|
||||
'hasPart' => $artworks->getCollection()->take(6)->map(fn($a) => [
|
||||
'@type' => 'ImageObject',
|
||||
'name' => $a->title,
|
||||
'url' => url('/' . ($a->slug ?? $a->id)),
|
||||
'thumbnail' => $a->thumbUrl('sm'),
|
||||
])->values()->all(),
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}</script>
|
||||
@if($artworks->previousPageUrl())
|
||||
<link rel="prev" href="{{ $artworks->previousPageUrl() }}">
|
||||
@endif
|
||||
@if($artworks->nextPageUrl())
|
||||
<link rel="next" href="{{ $artworks->nextPageUrl() }}">
|
||||
@endif
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="container legacy-page">
|
||||
<div class="effect2">
|
||||
<div class="page-heading">
|
||||
<h1 class="page-header">Tag: {{ $tag->name }}</h1>
|
||||
<p class="text-muted">Browse artworks tagged with “{{ $tag->name }}”.</p>
|
||||
<div class="px-6 py-8 md:px-10">
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="mb-6 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-sky-500/15 text-sky-400">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold text-white">{{ $tag->name }}</h1>
|
||||
</div>
|
||||
<p class="text-sm text-neutral-400">
|
||||
{{ number_format($artworks->total()) }} {{ Str::plural('artwork', $artworks->total()) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-skinbase effect2">
|
||||
<div class="panel-body">
|
||||
@if($artworks->isEmpty())
|
||||
<div class="alert alert-info">No artworks found for this tag.</div>
|
||||
@else
|
||||
<div class="row">
|
||||
@foreach($artworks as $artwork)
|
||||
<div class="col-xs-6 col-sm-4 col-md-3" style="margin-bottom:16px">
|
||||
<a href="/{{ $artwork->slug }}" title="{{ $artwork->title }}" style="display:block">
|
||||
<img src="{{ $artwork->thumb_url ?? $artwork->thumb }}" class="img-responsive img-thumbnail" alt="{{ $artwork->title }}" style="width:100%;height:160px;object-fit:cover">
|
||||
</a>
|
||||
<div style="margin-top:6px;font-weight:700;line-height:1.2">
|
||||
<a href="/{{ $artwork->slug }}">{{ str($artwork->title)->limit(60) }}</a>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="paginationMenu text-center">
|
||||
{{ $artworks->links('pagination::bootstrap-3') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
{{-- Sort controls --}}
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
@foreach(['popular' => 'Most viewed', 'latest' => 'Newest', 'likes' => 'Most liked', 'downloads' => 'Most downloaded'] as $key => $label)
|
||||
<a href="{{ route('tags.show', [$tag->slug, 'sort' => $key]) }}"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors
|
||||
{{ $sort === $key ? 'bg-sky-500 text-white' : 'bg-white/5 text-neutral-400 hover:bg-white/10 hover:text-white' }}">
|
||||
{{ $label }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Grid --}}
|
||||
@if($artworks->isEmpty())
|
||||
<div class="rounded-xl bg-white/[0.03] border border-white/[0.06] p-10 text-center text-neutral-400">
|
||||
No artworks found for this tag yet.
|
||||
</div>
|
||||
@else
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
|
||||
@foreach($artworks as $artwork)
|
||||
<x-artwork-card :art="$artwork" />
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-10">
|
||||
{{ $artworks->appends(['sort' => $sort])->links('pagination::tailwind') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
112
resources/views/web/authors/top.blade.php
Normal file
112
resources/views/web/authors/top.blade.php
Normal file
@@ -0,0 +1,112 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
|
||||
{{-- ── Hero header ── --}}
|
||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Community</p>
|
||||
<h1 class="text-3xl font-bold text-white leading-tight">Top Authors</h1>
|
||||
<p class="mt-1 text-sm text-white/50">Most popular members ranked by artwork {{ $metric === 'downloads' ? 'downloads' : 'views' }}.</p>
|
||||
</div>
|
||||
|
||||
{{-- Metric switcher --}}
|
||||
<nav class="flex items-center gap-2" aria-label="Ranking metric">
|
||||
<a href="{{ request()->fullUrlWithQuery(['metric' => 'views']) }}"
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-4 py-1.5 text-xs font-medium border transition-colors
|
||||
{{ $metric === 'views' ? 'bg-sky-500/15 text-sky-300 border-sky-500/30' : 'border-white/[0.08] bg-white/[0.04] text-white/55 hover:text-white hover:bg-white/[0.08]' }}">
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
Views
|
||||
</a>
|
||||
<a href="{{ request()->fullUrlWithQuery(['metric' => 'downloads']) }}"
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-4 py-1.5 text-xs font-medium border transition-colors
|
||||
{{ $metric === 'downloads' ? 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30' : 'border-white/[0.08] bg-white/[0.04] text-white/55 hover:text-white hover:bg-white/[0.08]' }}">
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
Downloads
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Leaderboard ── --}}
|
||||
<div class="px-6 pb-16 md:px-10">
|
||||
@php $offset = ($authors->currentPage() - 1) * $authors->perPage(); @endphp
|
||||
|
||||
@if ($authors->isNotEmpty())
|
||||
<div class="rounded-xl border border-white/[0.06] overflow-hidden">
|
||||
|
||||
{{-- Table header --}}
|
||||
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-3 bg-white/[0.03] border-b border-white/[0.06]">
|
||||
<span class="text-xs font-semibold uppercase tracking-widest text-white/30 text-center">#</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-widest text-white/30">Author</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-widest text-white/30 text-right">
|
||||
{{ $metric === 'downloads' ? 'Downloads' : 'Views' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Rows --}}
|
||||
<div class="divide-y divide-white/[0.04]">
|
||||
@foreach ($authors as $i => $author)
|
||||
@php
|
||||
$rank = $offset + $i + 1;
|
||||
$profileUrl = ($author->username ?? null)
|
||||
? '/@' . $author->username
|
||||
: '/profile/' . (int) $author->user_id;
|
||||
$avatarUrl = \App\Support\AvatarUrl::forUser((int) $author->user_id, null, 40);
|
||||
@endphp
|
||||
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-4
|
||||
{{ $rank <= 3 ? 'bg-white/[0.015]' : '' }} hover:bg-white/[0.03] transition-colors">
|
||||
|
||||
{{-- Rank badge --}}
|
||||
<div class="text-center">
|
||||
@if ($rank === 1)
|
||||
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-amber-400/15 text-amber-300 text-xs font-bold ring-1 ring-amber-400/30">1</span>
|
||||
@elseif ($rank === 2)
|
||||
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-slate-400/15 text-slate-300 text-xs font-bold ring-1 ring-slate-400/30">2</span>
|
||||
@elseif ($rank === 3)
|
||||
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-orange-700/20 text-orange-400 text-xs font-bold ring-1 ring-orange-600/30">3</span>
|
||||
@else
|
||||
<span class="text-sm text-white/30 font-medium">{{ $rank }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Author info --}}
|
||||
<a href="{{ $profileUrl }}" class="flex items-center gap-3 min-w-0 hover:opacity-90 transition-opacity">
|
||||
<img src="{{ $avatarUrl }}" alt="{{ $author->uname }}"
|
||||
class="w-9 h-9 rounded-full object-cover flex-shrink-0 ring-1 ring-white/[0.08]">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold text-white/90">{{ $author->uname ?? 'Unknown' }}</div>
|
||||
@if (!empty($author->username))
|
||||
<div class="truncate text-xs text-white/35">{{ '@' . $author->username }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{{-- Metric count --}}
|
||||
<div class="text-right flex-shrink-0">
|
||||
<span class="text-sm font-semibold {{ $metric === 'downloads' ? 'text-emerald-400' : 'text-sky-400' }}">
|
||||
{{ number_format($author->total ?? 0) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-center">
|
||||
{{ $authors->withQueryString()->links() }}
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<p class="text-white/40 text-sm">No authors found.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
82
resources/views/web/comments/latest.blade.php
Normal file
82
resources/views/web/comments/latest.blade.php
Normal file
@@ -0,0 +1,82 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
|
||||
{{-- ── Hero header ── --}}
|
||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Community</p>
|
||||
<h1 class="text-3xl font-bold text-white leading-tight">Latest Comments</h1>
|
||||
<p class="mt-1 text-sm text-white/50">Most recent artwork comments from the community.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Comment cards grid ── --}}
|
||||
<div class="px-6 pb-16 md:px-10">
|
||||
@if ($comments->isNotEmpty())
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
@foreach ($comments as $comment)
|
||||
@php
|
||||
$artUrl = '/art/' . (int)($comment->id ?? 0) . '/' . ($comment->artwork_slug ?? 'artwork');
|
||||
$userUrl = '/profile/' . (int)($comment->commenter_id ?? 0) . '/' . rawurlencode($comment->uname ?? 'user');
|
||||
$avatarUrl = \App\Support\AvatarUrl::forUser((int)($comment->commenter_id ?? 0), $comment->icon ?? null, 40);
|
||||
$ago = \Carbon\Carbon::parse($comment->datetime ?? now())->diffForHumans();
|
||||
$snippet = \Illuminate\Support\Str::limit(strip_tags($comment->comment_description ?? ''), 160);
|
||||
@endphp
|
||||
|
||||
<article class="flex flex-col rounded-xl border border-white/[0.06] bg-white/[0.03] hover:border-white/[0.1] hover:bg-white/[0.05] transition-all duration-200 overflow-hidden">
|
||||
|
||||
{{-- Artwork thumbnail --}}
|
||||
@if (!empty($comment->thumb))
|
||||
<a href="{{ $artUrl }}" class="block overflow-hidden bg-neutral-900 flex-shrink-0">
|
||||
<img src="{{ $comment->thumb }}" alt="{{ $comment->name ?? 'Artwork' }}"
|
||||
class="w-full h-36 object-cover transition-transform duration-300 hover:scale-[1.03]"
|
||||
loading="lazy">
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col flex-1 p-4 gap-3">
|
||||
|
||||
{{-- Commenter row --}}
|
||||
<div class="flex items-center gap-2.5">
|
||||
<a href="{{ $userUrl }}" class="flex-shrink-0">
|
||||
<img src="{{ $avatarUrl }}" alt="{{ $comment->uname ?? 'User' }}"
|
||||
class="w-8 h-8 rounded-full object-cover ring-1 ring-white/[0.08]">
|
||||
</a>
|
||||
<div class="min-w-0 flex-1">
|
||||
<a href="{{ $userUrl }}" class="block truncate text-xs font-semibold text-white/85 hover:text-white transition-colors">
|
||||
{{ $comment->uname ?? 'Unknown' }}
|
||||
</a>
|
||||
<span class="text-[10px] text-white/35">{{ $ago }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Comment text --}}
|
||||
<p class="flex-1 text-sm text-white/60 leading-relaxed line-clamp-4">{{ $snippet }}</p>
|
||||
|
||||
{{-- Artwork link footer --}}
|
||||
@if (!empty($comment->name))
|
||||
<a href="{{ $artUrl }}"
|
||||
class="mt-auto inline-flex items-center gap-1.5 text-xs text-sky-400/70 hover:text-sky-300 transition-colors truncate">
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<span class="truncate">{{ $comment->name }}</span>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex justify-center">
|
||||
{{ $comments->withQueryString()->links() }}
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<p class="text-white/40 text-sm">No comments found.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
90
resources/views/web/comments/monthly.blade.php
Normal file
90
resources/views/web/comments/monthly.blade.php
Normal file
@@ -0,0 +1,90 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
|
||||
{{-- ── Hero header ── --}}
|
||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Community</p>
|
||||
<h1 class="text-3xl font-bold text-white leading-tight">Monthly Top Commentators</h1>
|
||||
<p class="mt-1 text-sm text-white/50">Members who posted the most comments in the last 30 days.</p>
|
||||
</div>
|
||||
<span class="flex-shrink-0 inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium bg-violet-500/10 text-violet-300 ring-1 ring-violet-500/25">
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||
</svg>
|
||||
Last 30 days
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Leaderboard ── --}}
|
||||
<div class="px-6 pb-16 md:px-10">
|
||||
@php $offset = ($rows->currentPage() - 1) * $rows->perPage(); @endphp
|
||||
|
||||
@if ($rows->isNotEmpty())
|
||||
<div class="rounded-xl border border-white/[0.06] overflow-hidden">
|
||||
|
||||
{{-- Table header --}}
|
||||
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-3 bg-white/[0.03] border-b border-white/[0.06]">
|
||||
<span class="text-xs font-semibold uppercase tracking-widest text-white/30 text-center">#</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-widest text-white/30">Member</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-widest text-white/30 text-right">Comments</span>
|
||||
</div>
|
||||
|
||||
{{-- Rows --}}
|
||||
<div class="divide-y divide-white/[0.04]">
|
||||
@foreach ($rows as $i => $row)
|
||||
@php
|
||||
$rank = $offset + $i + 1;
|
||||
$profileUrl = '/profile/' . (int)($row->user_id ?? 0) . '/' . rawurlencode($row->uname ?? 'user');
|
||||
$avatarUrl = \App\Support\AvatarUrl::forUser((int)($row->user_id ?? 0), null, 40);
|
||||
@endphp
|
||||
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-4
|
||||
{{ $rank <= 3 ? 'bg-white/[0.015]' : '' }} hover:bg-white/[0.03] transition-colors">
|
||||
|
||||
{{-- Rank badge --}}
|
||||
<div class="text-center">
|
||||
@if ($rank === 1)
|
||||
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-amber-400/15 text-amber-300 text-xs font-bold ring-1 ring-amber-400/30">1</span>
|
||||
@elseif ($rank === 2)
|
||||
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-slate-400/15 text-slate-300 text-xs font-bold ring-1 ring-slate-400/30">2</span>
|
||||
@elseif ($rank === 3)
|
||||
<span class="inline-flex items-center justify-center w-7 h-7 rounded-full bg-orange-700/20 text-orange-400 text-xs font-bold ring-1 ring-orange-600/30">3</span>
|
||||
@else
|
||||
<span class="text-sm text-white/30 font-medium">{{ $rank }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Member info --}}
|
||||
<a href="{{ $profileUrl }}" class="flex items-center gap-3 min-w-0 hover:opacity-90 transition-opacity">
|
||||
<img src="{{ $avatarUrl }}" alt="{{ $row->uname ?? 'User' }}"
|
||||
class="w-9 h-9 rounded-full object-cover flex-shrink-0 ring-1 ring-white/[0.08]">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold text-white/90">{{ $row->uname ?? 'Unknown' }}</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{{-- Comment count --}}
|
||||
<div class="text-right flex-shrink-0">
|
||||
<span class="text-sm font-semibold text-violet-400">
|
||||
{{ number_format((int)($row->num_comments ?? 0)) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-center">
|
||||
{{ $rows->withQueryString()->links() }}
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<p class="text-white/40 text-sm">No comment activity in the last 30 days.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
@@ -1,47 +1,111 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="effect2 page-header-wrap">
|
||||
<header class="page-heading">
|
||||
<h1 class="page-header">Daily Uploads</h1>
|
||||
<p>List of all latest uploaded Artworks - <strong>Skins</strong>, <strong>Photography</strong> and <strong>Wallpapers</strong> to Skinbase ordered by upload date.</p>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default uploads-panel effect2">
|
||||
<div class="panel-body">
|
||||
<b>Choose date:</b>
|
||||
<ul id="recentTab">
|
||||
@foreach($dates as $i => $d)
|
||||
<li id="tab-{{ $i+1 }}" data-iso="{{ $d['iso'] }}">{{ $d['label'] }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
<div id="myContent">
|
||||
@include('web.partials.daily-uploads-grid', ['arts' => $recent])
|
||||
</div>
|
||||
{{-- ── Hero header ── --}}
|
||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Skinbase</p>
|
||||
<h1 class="text-3xl font-bold text-white leading-tight">Daily Uploads</h1>
|
||||
<p class="mt-1 text-sm text-white/50">Browse all artworks uploaded on a specific date.</p>
|
||||
</div>
|
||||
<a href="{{ route('uploads.latest') }}"
|
||||
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
Latest Uploads
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Date strip ── --}}
|
||||
<div class="px-6 md:px-10 pb-5">
|
||||
<div class="flex items-center gap-1.5 overflow-x-auto pb-1 scrollbar-none" id="dateStrip">
|
||||
@foreach($dates as $i => $d)
|
||||
<button type="button"
|
||||
data-iso="{{ $d['iso'] }}"
|
||||
id="tab-{{ $i+1 }}"
|
||||
class="flex-shrink-0 rounded-lg px-3.5 py-1.5 text-xs font-medium border transition-colors
|
||||
{{ $i === 0
|
||||
? 'bg-sky-500/15 text-sky-300 border-sky-500/30 active-date-tab'
|
||||
: 'border-white/[0.08] bg-white/[0.03] text-white/50 hover:text-white hover:bg-white/[0.07]' }}">
|
||||
@if ($i === 0)
|
||||
Today
|
||||
@elseif ($i === 1)
|
||||
Yesterday
|
||||
@else
|
||||
{{ \Carbon\Carbon::parse($d['iso'])->format('M j') }}
|
||||
@endif
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Active date label ── --}}
|
||||
<div class="px-6 md:px-10 mb-4">
|
||||
<p id="activeDateLabel" class="text-sm text-white/40">
|
||||
Showing uploads from <strong class="text-white/70">{{ $dates[0]['label'] ?? 'today' }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- ── Grid container ── --}}
|
||||
<div id="myContent" class="px-6 pb-16 md:px-10 min-h-48">
|
||||
@include('web.partials.daily-uploads-grid', ['arts' => $recent])
|
||||
</div>
|
||||
|
||||
{{-- ── Loading overlay (hidden) ── --}}
|
||||
<template id="loadingTpl">
|
||||
<div class="flex items-center justify-center py-20 text-white/30 text-sm gap-2">
|
||||
<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
Loading artworks…
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
(function(){
|
||||
function loadDate(iso, tabId){
|
||||
var el = document.getElementById('myContent');
|
||||
fetch('/daily-uploads?ajax=1&datum=' + encodeURIComponent(iso))
|
||||
.then(function(r){ return r.text(); })
|
||||
.then(function(html){ el.innerHTML = html; });
|
||||
}
|
||||
(function () {
|
||||
var endpoint = '/uploads/daily';
|
||||
var strip = document.getElementById('dateStrip');
|
||||
var content = document.getElementById('myContent');
|
||||
var dateLabel = document.getElementById('activeDateLabel');
|
||||
var loadingTpl = document.getElementById('loadingTpl');
|
||||
|
||||
document.getElementById('recentTab').addEventListener('click', function(e){
|
||||
var li = e.target.closest('li');
|
||||
if (!li) return;
|
||||
var iso = li.getAttribute('data-iso');
|
||||
loadDate(iso, li.id);
|
||||
function setActive(btn) {
|
||||
strip.querySelectorAll('button').forEach(function (b) {
|
||||
b.classList.remove('bg-sky-500/15', 'text-sky-300', 'border-sky-500/30', 'active-date-tab');
|
||||
b.classList.add('border-white/[0.08]', 'bg-white/[0.03]', 'text-white/50');
|
||||
});
|
||||
})();
|
||||
btn.classList.add('bg-sky-500/15', 'text-sky-300', 'border-sky-500/30', 'active-date-tab');
|
||||
btn.classList.remove('border-white/[0.08]', 'bg-white/[0.03]', 'text-white/50');
|
||||
}
|
||||
|
||||
function loadDate(iso, label) {
|
||||
content.innerHTML = loadingTpl.innerHTML;
|
||||
dateLabel.innerHTML = 'Showing uploads from <strong class="text-white/70">' + label + '</strong>';
|
||||
|
||||
fetch(endpoint + '?ajax=1&datum=' + encodeURIComponent(iso), {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (html) { content.innerHTML = html; })
|
||||
.catch(function () {
|
||||
content.innerHTML = '<p class="text-center text-white/30 py-16 text-sm">Failed to load artworks.</p>';
|
||||
});
|
||||
}
|
||||
|
||||
strip.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('button[data-iso]');
|
||||
if (!btn || btn.classList.contains('active-date-tab')) return;
|
||||
setActive(btn);
|
||||
var label = btn.textContent.trim();
|
||||
loadDate(btn.getAttribute('data-iso'), label);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
|
||||
73
resources/views/web/downloads/today.blade.php
Normal file
73
resources/views/web/downloads/today.blade.php
Normal file
@@ -0,0 +1,73 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
|
||||
{{-- ── Hero header ── --}}
|
||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Downloads</p>
|
||||
<h1 class="text-3xl font-bold text-white leading-tight">Most Downloaded Today</h1>
|
||||
<p class="mt-1 text-sm text-white/50">
|
||||
Artworks downloaded the most on <time datetime="{{ now()->toDateString() }}">{{ now()->format('d F Y') }}</time>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0 flex items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium bg-emerald-500/10 text-emerald-300 ring-1 ring-emerald-500/25">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||
Live today
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Artwork grid ── --}}
|
||||
<div class="px-6 pb-16 md:px-10">
|
||||
@if ($artworks && $artworks->isNotEmpty())
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-5">
|
||||
@foreach ($artworks as $art)
|
||||
@php
|
||||
$card = (object)[
|
||||
'id' => $art->id ?? null,
|
||||
'name' => $art->name ?? 'Artwork',
|
||||
'thumb' => $art->thumb ?? null,
|
||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||
'uname' => $art->uname ?? '',
|
||||
'category_name' => $art->category_name ?? '',
|
||||
'slug' => $art->slug ?? \Illuminate\Support\Str::slug($art->name ?? 'artwork'),
|
||||
];
|
||||
$downloads = (int) ($art->num_downloads ?? 0);
|
||||
@endphp
|
||||
|
||||
{{-- Wrap card to overlay download badge --}}
|
||||
<div class="relative">
|
||||
<x-artwork-card :art="$card" />
|
||||
@if ($downloads > 0)
|
||||
<div class="absolute top-2 left-2 z-40 pointer-events-none">
|
||||
<span class="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-semibold bg-black/60 text-emerald-300 backdrop-blur-sm ring-1 ring-emerald-500/30">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
{{ number_format($downloads) }}
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex justify-center">
|
||||
{{ $artworks->withQueryString()->links() }}
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<svg class="mx-auto mb-3 w-10 h-10 text-white/20" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
<p class="text-white/40 text-sm">No downloads recorded today yet.</p>
|
||||
<p class="text-white/25 text-xs mt-1">Check back later as the day progresses.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
@@ -7,7 +7,7 @@
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="min-h-screen">
|
||||
@include('web.home.featured')
|
||||
|
||||
@include('web.home.uploads')
|
||||
|
||||
@@ -1,37 +1,46 @@
|
||||
|
||||
{{-- Featured row — use Nova cards for consistent layout with browse/gallery --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
@if(!empty($featured))
|
||||
<div>
|
||||
@include('web.partials._artwork_card', ['art' => $featured])
|
||||
</div>
|
||||
@else
|
||||
<div class="panel panel-default effect2">
|
||||
<div class="panel-heading"><strong>Featured Artwork</strong></div>
|
||||
<div class="panel-body text-neutral-400">No featured artwork set.</div>
|
||||
</div>
|
||||
@endif
|
||||
<section class="px-6 pt-8 pb-6 md:px-10">
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<span class="inline-flex items-center justify-center w-7 h-7 rounded-md bg-amber-500/15 text-amber-400">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/></svg>
|
||||
</span>
|
||||
<h2 class="text-base font-semibold text-white/90 tracking-wide uppercase">Featured</h2>
|
||||
</div>
|
||||
|
||||
@if(!empty($memberFeatured))
|
||||
<div>
|
||||
@include('web.partials._artwork_card', ['art' => $memberFeatured])
|
||||
</div>
|
||||
@else
|
||||
<div class="panel panel-default effect2">
|
||||
<div class="panel-heading"><strong>Member Featured</strong></div>
|
||||
<div class="panel-body text-neutral-400">No member featured artwork.</div>
|
||||
</div>
|
||||
@endif
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
@if(!empty($featured))
|
||||
<div>
|
||||
@include('web.partials._artwork_card', ['art' => $featured])
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-2xl ring-1 ring-white/5 bg-white/[0.03] p-4">
|
||||
<p class="text-sm text-neutral-400">No featured artwork set.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<div class="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg p-4 text-center">
|
||||
<a href="{{ route('register') }}" title="Join Skinbase" class="inline-block mb-3">
|
||||
<img src="/gfx/sb_join.jpg" alt="Join SkinBase Community" class="w-full h-40 object-cover rounded-lg">
|
||||
</a>
|
||||
<div class="text-lg font-semibold text-white/90">Join Skinbase World</div>
|
||||
<p class="mt-2 text-sm text-neutral-400">Join Skinbase and be part of our community. Upload, share and explore curated photography and skins.</p>
|
||||
<a href="{{ route('register') }}" class="mt-3 inline-block px-4 py-2 rounded-md bg-sky-500 text-white">Create an account</a>
|
||||
@if(!empty($memberFeatured))
|
||||
<div>
|
||||
@include('web.partials._artwork_card', ['art' => $memberFeatured])
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-2xl ring-1 ring-white/5 bg-white/[0.03] p-4">
|
||||
<p class="text-sm text-neutral-400">No member featured artwork.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<div class="group relative flex flex-col overflow-hidden rounded-2xl ring-1 ring-white/5 bg-white/[0.03] shadow-lg h-full">
|
||||
<a href="{{ route('register') }}" title="Join Skinbase" class="block shrink-0">
|
||||
<img src="/gfx/sb_join.jpg" alt="Join SkinBase Community" class="w-full h-48 object-cover">
|
||||
</a>
|
||||
<div class="flex flex-col flex-1 p-5 text-center">
|
||||
<div class="text-lg font-semibold text-white/90">Join Skinbase World</div>
|
||||
<p class="mt-2 text-sm text-neutral-400 flex-1">Join our community — upload, share and explore curated photography and skins.</p>
|
||||
<a href="{{ route('register') }}" class="mt-4 inline-block px-4 py-2 rounded-lg bg-sky-500 hover:bg-sky-400 transition-colors text-white text-sm font-medium">Create an account</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
48
resources/views/web/members/photos.blade.php
Normal file
48
resources/views/web/members/photos.blade.php
Normal file
@@ -0,0 +1,48 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
|
||||
{{-- ── Hero header ── --}}
|
||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Members</p>
|
||||
<h1 class="text-3xl font-bold text-white leading-tight">{{ $page_title ?? 'Member Photos' }}</h1>
|
||||
<p class="mt-1 text-sm text-white/50">Artwork submitted by the Skinbase community.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Artwork grid ── --}}
|
||||
<div class="px-6 pb-16 md:px-10">
|
||||
@php $items = is_object($artworks) && method_exists($artworks, 'toArray') ? $artworks : collect($artworks ?? []); @endphp
|
||||
|
||||
@if (!empty($artworks) && (is_countable($artworks) ? count($artworks) > 0 : true))
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-5">
|
||||
@foreach ($artworks as $art)
|
||||
@php
|
||||
$card = (object)[
|
||||
'id' => $art->id ?? null,
|
||||
'name' => $art->name ?? $art->title ?? 'Artwork',
|
||||
'thumb' => $art->thumb ?? $art->thumb_url ?? null,
|
||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||
'uname' => $art->uname ?? $art->author ?? '',
|
||||
'category_name' => $art->category_name ?? '',
|
||||
'slug' => $art->slug ?? \Illuminate\Support\Str::slug($art->name ?? 'artwork'),
|
||||
];
|
||||
@endphp
|
||||
<x-artwork-card :art="$card" />
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if (is_object($artworks) && method_exists($artworks, 'links'))
|
||||
<div class="mt-10 flex justify-center">
|
||||
{{ $artworks->withQueryString()->links() }}
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<p class="text-white/40 text-sm">No artworks found.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user