From 0032aec02fe2315eb156d738a41bcb095355feae Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Wed, 25 Feb 2026 19:11:23 +0100 Subject: [PATCH] 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 --- app/Console/Commands/AiTagArtworksCommand.php | 436 +++ app/Console/Commands/FixTagNamesCommand.php | 92 + app/Console/Commands/ImportLegacyAwards.php | 288 ++ app/Console/Commands/ImportLegacyComments.php | 266 ++ .../Commands/RebuildArtworkSearchIndex.php | 30 + app/Console/Kernel.php | 2 + .../Api/ArtworkAwardController.php | 121 + .../Api/ArtworkNavigationController.php | 71 + .../Api/Search/ArtworkSearchController.php | 109 + app/Http/Controllers/Api/TagController.php | 55 +- .../Community/InterviewController.php | 18 + .../Community/LatestCommentsController.php | 2 +- .../Community/LatestController.php | 3 +- .../Controllers/User/MembersController.php | 2 +- .../User/MonthlyCommentatorsController.php | 17 +- .../Controllers/User/ProfileController.php | 289 +- .../User/TodayDownloadsController.php | 2 +- .../User/TodayInHistoryController.php | 2 +- .../Controllers/User/TopAuthorsController.php | 3 +- .../User/TopFavouritesController.php | 2 +- .../Controllers/Web/ArtworkPageController.php | 23 + .../Web/BrowseGalleryController.php | 29 +- .../Controllers/Web/CategoryController.php | 5 + app/Http/Controllers/Web/HomeController.php | 11 +- app/Http/Controllers/Web/SearchController.php | 49 + .../Controllers/Web/SectionsController.php | 47 + app/Http/Controllers/Web/TagController.php | 53 +- app/Http/Requests/Tags/PopularTagsRequest.php | 7 +- app/Http/Requests/Tags/TagSearchRequest.php | 7 +- app/Jobs/DeleteArtworkFromIndexJob.php | 42 + app/Jobs/IndexArtworkJob.php | 52 + app/Models/Artwork.php | 74 +- app/Models/ArtworkAward.php | 44 + app/Models/ArtworkAwardStat.php | 40 + app/Models/ArtworkComment.php | 1 + app/Models/ProfileComment.php | 47 + app/Models/Tag.php | 6 + app/Models/TagSynonym.php | 25 + app/Models/User.php | 33 + app/Models/UserFollower.php | 37 + app/Models/UserStatistic.php | 30 + app/Observers/ArtworkAwardObserver.php | 40 + app/Observers/ArtworkObserver.php | 56 + app/Policies/ArtworkAwardPolicy.php | 69 + app/Providers/AppServiceProvider.php | 7 + app/Providers/AuthServiceProvider.php | 5 +- app/Services/ArtworkAwardService.php | 132 + app/Services/ArtworkSearchIndexer.php | 61 + app/Services/ArtworkSearchService.php | 191 ++ app/Services/ArtworkService.php | 2 +- app/Services/TagNormalizer.php | 67 +- app/Services/TagService.php | 29 +- config/scout.php | 140 + config/vision.php | 16 + database/factories/TagFactory.php | 34 + ..._23_000001_create_artwork_awards_table.php | 37 + ..._23_000001_create_user_followers_table.php | 33 + ...00002_create_artwork_award_stats_table.php | 29 + ...3_000002_create_profile_comments_table.php | 36 + ...dd_legacy_id_to_artwork_comments_table.php | 25 + ...profile_views_to_user_statistics_table.php | 25 + database/old_skinbase_structure.sql | 2127 +++++++++++++++ docs/tags-system.md | 210 ++ package.json | 3 + phpunit.xml | 1 + playwright-report/index.html | 85 + playwright.config.ts | 31 + public/gfx/sb_join.jpg | Bin 0 -> 12679 bytes resources/js/Pages/ArtworkPage.jsx | 121 +- resources/js/Search/SearchBar.jsx | 156 ++ resources/js/components/Topbar.jsx | 8 +- .../js/components/artwork/ArtworkAwards.jsx | 191 ++ .../components/artwork/ArtworkBreadcrumbs.jsx | 88 + .../js/components/artwork/ArtworkComments.jsx | 97 + .../js/components/artwork/ArtworkHero.jsx | 67 +- .../js/components/artwork/ArtworkMeta.jsx | 4 +- .../js/components/artwork/ArtworkTags.jsx | 2 +- .../js/components/viewer/ArtworkNavigator.jsx | 159 ++ .../js/components/viewer/ArtworkViewer.jsx | 96 + .../js/components/viewer/viewer.test.jsx | 439 +++ resources/js/entry-search.jsx | 15 + resources/js/lib/nav-context.js | 124 + resources/js/lib/useNavContext.js | 43 + resources/js/nova.js | 3 + resources/views/artworks/show.blade.php | 4 +- .../views/components/artwork-card.blade.php | 6 +- resources/views/gallery/index.blade.php | 2 +- resources/views/layouts/nova.blade.php | 2 +- .../views/layouts/nova/toolbar.blade.php | 14 +- resources/views/legacy/profile.blade.php | 692 ++++- resources/views/search/index.blade.php | 78 + resources/views/tags/show.blade.php | 102 +- resources/views/web/authors/top.blade.php | 112 + resources/views/web/comments/latest.blade.php | 82 + .../views/web/comments/monthly.blade.php | 90 + resources/views/web/daily-uploads.blade.php | 128 +- resources/views/web/downloads/today.blade.php | 73 + resources/views/web/home.blade.php | 2 +- resources/views/web/home/featured.blade.php | 69 +- resources/views/web/members/photos.blade.php | 48 + .../web/partials/daily-uploads-grid.blade.php | 13 +- resources/views/web/sections.blade.php | 174 ++ resources/views/web/uploads/latest.blade.php | 55 + routes/api.php | 48 +- routes/web.php | 22 + test-results/.last-run.json | 15 + .../error-context.md | 284 -- .../error-context.md | 57 + .../test-failed-1.png | Bin 0 -> 14154 bytes .../error-context.md | 57 + .../test-failed-1.png | Bin 0 -> 13967 bytes .../error-context.md | 2357 +++++++++++++++++ .../error-context.md | 646 +++++ .../test-failed-1.png | Bin 0 -> 477304 bytes .../error-context.md | 178 ++ .../test-failed-1.png | Bin 0 -> 37021 bytes .../error-context.md | 646 +++++ .../test-failed-1.png | Bin 0 -> 477398 bytes .../error-context.md | 646 +++++ .../test-failed-1.png | Bin 0 -> 477267 bytes .../error-context.md | 235 ++ .../test-failed-1.png | Bin 0 -> 33676 bytes .../error-context.md | 235 ++ .../test-failed-1.png | Bin 0 -> 34140 bytes .../error-context.md | 648 +++++ .../test-failed-1.png | Bin 0 -> 477593 bytes tests/Feature/ArtworkAwardTest.php | 292 ++ tests/Feature/TagPageTest.php | 63 + tests/Feature/TagSearchTest.php | 89 + tests/e2e/routes.spec.ts | 429 +++ vite.config.js | 2 + 131 files changed, 15674 insertions(+), 597 deletions(-) create mode 100644 app/Console/Commands/AiTagArtworksCommand.php create mode 100644 app/Console/Commands/FixTagNamesCommand.php create mode 100644 app/Console/Commands/ImportLegacyAwards.php create mode 100644 app/Console/Commands/ImportLegacyComments.php create mode 100644 app/Console/Commands/RebuildArtworkSearchIndex.php create mode 100644 app/Http/Controllers/Api/ArtworkAwardController.php create mode 100644 app/Http/Controllers/Api/ArtworkNavigationController.php create mode 100644 app/Http/Controllers/Api/Search/ArtworkSearchController.php create mode 100644 app/Http/Controllers/Web/SearchController.php create mode 100644 app/Http/Controllers/Web/SectionsController.php create mode 100644 app/Jobs/DeleteArtworkFromIndexJob.php create mode 100644 app/Jobs/IndexArtworkJob.php create mode 100644 app/Models/ArtworkAward.php create mode 100644 app/Models/ArtworkAwardStat.php create mode 100644 app/Models/ProfileComment.php create mode 100644 app/Models/TagSynonym.php create mode 100644 app/Models/UserFollower.php create mode 100644 app/Models/UserStatistic.php create mode 100644 app/Observers/ArtworkAwardObserver.php create mode 100644 app/Observers/ArtworkObserver.php create mode 100644 app/Policies/ArtworkAwardPolicy.php create mode 100644 app/Services/ArtworkAwardService.php create mode 100644 app/Services/ArtworkSearchIndexer.php create mode 100644 app/Services/ArtworkSearchService.php create mode 100644 config/scout.php create mode 100644 database/factories/TagFactory.php create mode 100644 database/migrations/2026_02_23_000001_create_artwork_awards_table.php create mode 100644 database/migrations/2026_02_23_000001_create_user_followers_table.php create mode 100644 database/migrations/2026_02_23_000002_create_artwork_award_stats_table.php create mode 100644 database/migrations/2026_02_23_000002_create_profile_comments_table.php create mode 100644 database/migrations/2026_02_23_000003_add_legacy_id_to_artwork_comments_table.php create mode 100644 database/migrations/2026_02_23_000003_add_profile_views_to_user_statistics_table.php create mode 100644 database/old_skinbase_structure.sql create mode 100644 docs/tags-system.md create mode 100644 playwright-report/index.html create mode 100644 public/gfx/sb_join.jpg create mode 100644 resources/js/Search/SearchBar.jsx create mode 100644 resources/js/components/artwork/ArtworkAwards.jsx create mode 100644 resources/js/components/artwork/ArtworkBreadcrumbs.jsx create mode 100644 resources/js/components/artwork/ArtworkComments.jsx create mode 100644 resources/js/components/viewer/ArtworkNavigator.jsx create mode 100644 resources/js/components/viewer/ArtworkViewer.jsx create mode 100644 resources/js/components/viewer/viewer.test.jsx create mode 100644 resources/js/entry-search.jsx create mode 100644 resources/js/lib/nav-context.js create mode 100644 resources/js/lib/useNavContext.js create mode 100644 resources/views/search/index.blade.php create mode 100644 resources/views/web/authors/top.blade.php create mode 100644 resources/views/web/comments/latest.blade.php create mode 100644 resources/views/web/comments/monthly.blade.php create mode 100644 resources/views/web/downloads/today.blade.php create mode 100644 resources/views/web/members/photos.blade.php create mode 100644 resources/views/web/sections.blade.php create mode 100644 resources/views/web/uploads/latest.blade.php create mode 100644 test-results/.last-run.json delete mode 100644 test-results/home-home-page-loads-and-shows-legacy-page-container-chromium/error-context.md create mode 100644 test-results/routes-404-routes-—-clean--2751e-ot-a-500-404-—-unknown-path-chromium/error-context.md create mode 100644 test-results/routes-404-routes-—-clean--2751e-ot-a-500-404-—-unknown-path-chromium/test-failed-1.png create mode 100644 test-results/routes-404-routes-—-clean--28a64-a-500-404-—-unknown-artwork-chromium/error-context.md create mode 100644 test-results/routes-404-routes-—-clean--28a64-a-500-404-—-unknown-artwork-chromium/test-failed-1.png create mode 100644 test-results/routes-Landmark-spot-check-21efb-ge-—-renders-category-links-chromium/error-context.md create mode 100644 test-results/routes-Landmark-spot-checks-Home-page-—-has-gallery-section-chromium/error-context.md create mode 100644 test-results/routes-Landmark-spot-checks-Home-page-—-has-gallery-section-chromium/test-failed-1.png create mode 100644 test-results/routes-Public-routes-—-200-no-errors-Categories-chromium/error-context.md create mode 100644 test-results/routes-Public-routes-—-200-no-errors-Categories-chromium/test-failed-1.png create mode 100644 test-results/routes-Public-routes-—-200-no-errors-Home-alias--chromium/error-context.md create mode 100644 test-results/routes-Public-routes-—-200-no-errors-Home-alias--chromium/test-failed-1.png create mode 100644 test-results/routes-Public-routes-—-200-no-errors-Home-page-chromium/error-context.md create mode 100644 test-results/routes-Public-routes-—-200-no-errors-Home-page-chromium/test-failed-1.png create mode 100644 test-results/routes-Public-routes-—-200-no-errors-Latest-legacy--chromium/error-context.md create mode 100644 test-results/routes-Public-routes-—-200-no-errors-Latest-legacy--chromium/test-failed-1.png create mode 100644 test-results/routes-Public-routes-—-200-no-errors-Latest-uploads-new--chromium/error-context.md create mode 100644 test-results/routes-Public-routes-—-200-no-errors-Latest-uploads-new--chromium/test-failed-1.png create mode 100644 test-results/routes-Public-routes-—-200-no-errors-Member-photos-new--chromium/error-context.md create mode 100644 test-results/routes-Public-routes-—-200-no-errors-Member-photos-new--chromium/test-failed-1.png create mode 100644 tests/Feature/ArtworkAwardTest.php create mode 100644 tests/Feature/TagPageTest.php create mode 100644 tests/Feature/TagSearchTest.php create mode 100644 tests/e2e/routes.spec.ts diff --git a/app/Console/Commands/AiTagArtworksCommand.php b/app/Console/Commands/AiTagArtworksCommand.php new file mode 100644 index 00000000..a01138f6 --- /dev/null +++ b/app/Console/Commands/AiTagArtworksCommand.php @@ -0,0 +1,436 @@ +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 + */ + 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('--- Payload (base64 truncated for display) ---'); + $this->line($printable); + $this->line(''); + $this->line('--- 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 + * @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 + */ + 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); + } +} diff --git a/app/Console/Commands/FixTagNamesCommand.php b/app/Console/Commands/FixTagNamesCommand.php new file mode 100644 index 00000000..99ccbf35 --- /dev/null +++ b/app/Console/Commands/FixTagNamesCommand.php @@ -0,0 +1,92 @@ +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; + } +} diff --git a/app/Console/Commands/ImportLegacyAwards.php b/app/Console/Commands/ImportLegacyAwards.php new file mode 100644 index 00000000..2d6b5561 --- /dev/null +++ b/app/Console/Commands/ImportLegacyAwards.php @@ -0,0 +1,288 @@ + '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; + } +} diff --git a/app/Console/Commands/ImportLegacyComments.php b/app/Console/Commands/ImportLegacyComments.php new file mode 100644 index 00000000..4671cfa9 --- /dev/null +++ b/app/Console/Commands/ImportLegacyComments.php @@ -0,0 +1,266 @@ +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; + } +} diff --git a/app/Console/Commands/RebuildArtworkSearchIndex.php b/app/Console/Commands/RebuildArtworkSearchIndex.php new file mode 100644 index 00000000..ebdefd8f --- /dev/null +++ b/app/Console/Commands/RebuildArtworkSearchIndex.php @@ -0,0 +1,30 @@ +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; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index f3641d81..42f5ee40 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -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, ]; /** diff --git a/app/Http/Controllers/Api/ArtworkAwardController.php b/app/Http/Controllers/Api/ArtworkAwardController.php new file mode 100644 index 00000000..36d1b2cb --- /dev/null +++ b/app/Http/Controllers/Api/ArtworkAwardController.php @@ -0,0 +1,121 @@ +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, + ]; + } +} diff --git a/app/Http/Controllers/Api/ArtworkNavigationController.php b/app/Http/Controllers/Api/ArtworkNavigationController.php new file mode 100644 index 00000000..88432473 --- /dev/null +++ b/app/Http/Controllers/Api/ArtworkNavigationController.php @@ -0,0 +1,71 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/Search/ArtworkSearchController.php b/app/Http/Controllers/Api/Search/ArtworkSearchController.php new file mode 100644 index 00000000..24132f1e --- /dev/null +++ b/app/Http/Controllers/Api/Search/ArtworkSearchController.php @@ -0,0 +1,109 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/TagController.php b/app/Http/Controllers/Api/TagController.php index 3b629d2d..bd0a2a81 100644 --- a/app/Http/Controllers/Api/TagController.php +++ b/app/Http/Controllers/Api/TagController.php @@ -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]); } } diff --git a/app/Http/Controllers/Community/InterviewController.php b/app/Http/Controllers/Community/InterviewController.php index f1352925..e715f26e 100644 --- a/app/Http/Controllers/Community/InterviewController.php +++ b/app/Http/Controllers/Community/InterviewController.php @@ -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; diff --git a/app/Http/Controllers/Community/LatestCommentsController.php b/app/Http/Controllers/Community/LatestCommentsController.php index 14c4cb6b..340927ca 100644 --- a/app/Http/Controllers/Community/LatestCommentsController.php +++ b/app/Http/Controllers/Community/LatestCommentsController.php @@ -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')); } } diff --git a/app/Http/Controllers/Community/LatestController.php b/app/Http/Controllers/Community/LatestController.php index 5f0c4723..dfcc81c3 100644 --- a/app/Http/Controllers/Community/LatestController.php +++ b/app/Http/Controllers/Community/LatestController.php @@ -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', ]); diff --git a/app/Http/Controllers/User/MembersController.php b/app/Http/Controllers/User/MembersController.php index d1320f8d..374a8692 100644 --- a/app/Http/Controllers/User/MembersController.php +++ b/app/Http/Controllers/User/MembersController.php @@ -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')); } } diff --git a/app/Http/Controllers/User/MonthlyCommentatorsController.php b/app/Http/Controllers/User/MonthlyCommentatorsController.php index a89cae22..257b0013 100644 --- a/app/Http/Controllers/User/MonthlyCommentatorsController.php +++ b/app/Http/Controllers/User/MonthlyCommentatorsController.php @@ -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')); } } diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index eda83616..31c38e86 100644 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -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.', ]); } } diff --git a/app/Http/Controllers/User/TodayDownloadsController.php b/app/Http/Controllers/User/TodayDownloadsController.php index e688bdac..95989d2c 100644 --- a/app/Http/Controllers/User/TodayDownloadsController.php +++ b/app/Http/Controllers/User/TodayDownloadsController.php @@ -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]); } } diff --git a/app/Http/Controllers/User/TodayInHistoryController.php b/app/Http/Controllers/User/TodayInHistoryController.php index 524f9d30..e13e8624 100644 --- a/app/Http/Controllers/User/TodayInHistoryController.php +++ b/app/Http/Controllers/User/TodayInHistoryController.php @@ -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', ]); diff --git a/app/Http/Controllers/User/TopAuthorsController.php b/app/Http/Controllers/User/TopAuthorsController.php index 314afd4e..6367838a 100644 --- a/app/Http/Controllers/User/TopAuthorsController.php +++ b/app/Http/Controllers/User/TopAuthorsController.php @@ -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')); } } diff --git a/app/Http/Controllers/User/TopFavouritesController.php b/app/Http/Controllers/User/TopFavouritesController.php index b0c2ac0a..42d551ec 100644 --- a/app/Http/Controllers/User/TopFavouritesController.php +++ b/app/Http/Controllers/User/TopFavouritesController.php @@ -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]); } } diff --git a/app/Http/Controllers/Web/ArtworkPageController.php b/app/Http/Controllers/Web/ArtworkPageController.php index aa999bca..6f0b06fe 100644 --- a/app/Http/Controllers/Web/ArtworkPageController.php +++ b/app/Http/Controllers/Web/ArtworkPageController.php @@ -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, ]); } } diff --git a/app/Http/Controllers/Web/BrowseGalleryController.php b/app/Http/Controllers/Web/BrowseGalleryController.php index 02525b9e..cdeb4d64 100644 --- a/app/Http/Controllers/Web/BrowseGalleryController.php +++ b/app/Http/Controllers/Web/BrowseGalleryController.php @@ -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(); diff --git a/app/Http/Controllers/Web/CategoryController.php b/app/Http/Controllers/Web/CategoryController.php index 385eeae7..e75f2b58 100644 --- a/app/Http/Controllers/Web/CategoryController.php +++ b/app/Http/Controllers/Web/CategoryController.php @@ -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(), '/'); diff --git a/app/Http/Controllers/Web/HomeController.php b/app/Http/Controllers/Web/HomeController.php index 39a82a5f..30ca51fe 100644 --- a/app/Http/Controllers/Web/HomeController.php +++ b/app/Http/Controllers/Web/HomeController.php @@ -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) diff --git a/app/Http/Controllers/Web/SearchController.php b/app/Http/Controllers/Web/SearchController.php new file mode 100644 index 00000000..3c1ddd68 --- /dev/null +++ b/app/Http/Controllers/Web/SearchController.php @@ -0,0 +1,49 @@ +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', + ]); + } +} diff --git a/app/Http/Controllers/Web/SectionsController.php b/app/Http/Controllers/Web/SectionsController.php new file mode 100644 index 00000000..323aa7aa --- /dev/null +++ b/app/Http/Controllers/Web/SectionsController.php @@ -0,0 +1,47 @@ + 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.', + ]); + } +} diff --git a/app/Http/Controllers/Web/TagController.php b/app/Http/Controllers/Web/TagController.php index cb9fc431..c74d0de5 100644 --- a/app/Http/Controllers/Web/TagController.php +++ b/app/Http/Controllers/Web/TagController.php @@ -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', ]); } } + diff --git a/app/Http/Requests/Tags/PopularTagsRequest.php b/app/Http/Requests/Tags/PopularTagsRequest.php index 745c2826..77e1e047 100644 --- a/app/Http/Requests/Tags/PopularTagsRequest.php +++ b/app/Http/Requests/Tags/PopularTagsRequest.php @@ -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 diff --git a/app/Http/Requests/Tags/TagSearchRequest.php b/app/Http/Requests/Tags/TagSearchRequest.php index 75960e5c..f7e15ec3 100644 --- a/app/Http/Requests/Tags/TagSearchRequest.php +++ b/app/Http/Requests/Tags/TagSearchRequest.php @@ -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 diff --git a/app/Jobs/DeleteArtworkFromIndexJob.php b/app/Jobs/DeleteArtworkFromIndexJob.php new file mode 100644 index 00000000..b3fdef7b --- /dev/null +++ b/app/Jobs/DeleteArtworkFromIndexJob.php @@ -0,0 +1,42 @@ +id = $this->artworkId; + $artwork->unsearchable(); + } + + public function failed(\Throwable $e): void + { + Log::error('DeleteArtworkFromIndexJob failed', [ + 'artwork_id' => $this->artworkId, + 'error' => $e->getMessage(), + ]); + } +} diff --git a/app/Jobs/IndexArtworkJob.php b/app/Jobs/IndexArtworkJob.php new file mode 100644 index 00000000..ec13f181 --- /dev/null +++ b/app/Jobs/IndexArtworkJob.php @@ -0,0 +1,52 @@ +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(), + ]); + } +} diff --git a/app/Models/Artwork.php b/app/Models/Artwork.php index a7cf81ee..aedae5fb 100644 --- a/app/Models/Artwork.php +++ b/app/Models/Artwork.php @@ -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 { diff --git a/app/Models/ArtworkAward.php b/app/Models/ArtworkAward.php new file mode 100644 index 00000000..3943540f --- /dev/null +++ b/app/Models/ArtworkAward.php @@ -0,0 +1,44 @@ + '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); + } +} diff --git a/app/Models/ArtworkAwardStat.php b/app/Models/ArtworkAwardStat.php new file mode 100644 index 00000000..b280486e --- /dev/null +++ b/app/Models/ArtworkAwardStat.php @@ -0,0 +1,40 @@ + '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); + } +} diff --git a/app/Models/ArtworkComment.php b/app/Models/ArtworkComment.php index 9b064932..9753f160 100644 --- a/app/Models/ArtworkComment.php +++ b/app/Models/ArtworkComment.php @@ -18,6 +18,7 @@ class ArtworkComment extends Model protected $table = 'artwork_comments'; protected $fillable = [ + 'legacy_id', 'artwork_id', 'user_id', 'content', diff --git a/app/Models/ProfileComment.php b/app/Models/ProfileComment.php new file mode 100644 index 00000000..082a5bad --- /dev/null +++ b/app/Models/ProfileComment.php @@ -0,0 +1,47 @@ + '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); + } +} diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 795cfae8..cfcf5e67 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -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'; diff --git a/app/Models/TagSynonym.php b/app/Models/TagSynonym.php new file mode 100644 index 00000000..0c915bc4 --- /dev/null +++ b/app/Models/TagSynonym.php @@ -0,0 +1,25 @@ +belongsTo(Tag::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index a7c844f6..bec58320 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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); diff --git a/app/Models/UserFollower.php b/app/Models/UserFollower.php new file mode 100644 index 00000000..fadd4d65 --- /dev/null +++ b/app/Models/UserFollower.php @@ -0,0 +1,37 @@ + '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'); + } +} diff --git a/app/Models/UserStatistic.php b/app/Models/UserStatistic.php new file mode 100644 index 00000000..70ebd1d7 --- /dev/null +++ b/app/Models/UserStatistic.php @@ -0,0 +1,30 @@ +belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Observers/ArtworkAwardObserver.php b/app/Observers/ArtworkAwardObserver.php new file mode 100644 index 00000000..cffc0b3b --- /dev/null +++ b/app/Observers/ArtworkAwardObserver.php @@ -0,0 +1,40 @@ +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); + } + } +} diff --git a/app/Observers/ArtworkObserver.php b/app/Observers/ArtworkObserver.php new file mode 100644 index 00000000..5458f065 --- /dev/null +++ b/app/Observers/ArtworkObserver.php @@ -0,0 +1,56 @@ +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); + } +} diff --git a/app/Policies/ArtworkAwardPolicy.php b/app/Policies/ArtworkAwardPolicy.php new file mode 100644 index 00000000..633c4412 --- /dev/null +++ b/app/Policies/ArtworkAwardPolicy.php @@ -0,0 +1,69 @@ +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; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 09b1c71b..3fe006be 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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; diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index e8f74f49..6c9cf494 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -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 */ protected $policies = [ - Artwork::class => ArtworkPolicy::class, + Artwork::class => ArtworkPolicy::class, + ArtworkAward::class => ArtworkAwardPolicy::class, ]; /** diff --git a/app/Services/ArtworkAwardService.php b/app/Services/ArtworkAwardService.php new file mode 100644 index 00000000..28d4cf3d --- /dev/null +++ b/app/Services/ArtworkAwardService.php @@ -0,0 +1,132 @@ +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.', + ]); + } + } +} diff --git a/app/Services/ArtworkSearchIndexer.php b/app/Services/ArtworkSearchIndexer.php new file mode 100644 index 00000000..c1fded90 --- /dev/null +++ b/app/Services/ArtworkSearchIndexer.php @@ -0,0 +1,61 @@ +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'); + } +} diff --git a/app/Services/ArtworkSearchService.php b/app/Services/ArtworkSearchService.php new file mode 100644 index 00000000..b51d8dd6 --- /dev/null +++ b/app/Services/ArtworkSearchService.php @@ -0,0 +1,191 @@ + — 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); + } +} diff --git a/app/Services/ArtworkService.php b/app/Services/ArtworkService.php index 9c0fc32e..d9757a28 100644 --- a/app/Services/ArtworkService.php +++ b/app/Services/ArtworkService.php @@ -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']); diff --git a/app/Services/TagNormalizer.php b/app/Services/TagNormalizer.php index 65f2f4ab..6aebac9d 100644 --- a/app/Services/TagNormalizer.php +++ b/app/Services/TagNormalizer.php @@ -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'); + } } diff --git a/app/Services/TagService.php b/app/Services/TagService.php index 596e3235..b3d10191 100644 --- a/app/Services/TagService.php +++ b/app/Services/TagService.php @@ -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); + } } diff --git a/config/scout.php b/config/scout.php new file mode 100644 index 00000000..c401c39d --- /dev/null +++ b/config/scout.php @@ -0,0 +1,140 @@ + 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', ''), + ], + +]; diff --git a/config/vision.php b/config/vision.php index 96d8c4e6..fdd787d3 100644 --- a/config/vision.php +++ b/config/vision.php @@ -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), + ], ]; diff --git a/database/factories/TagFactory.php b/database/factories/TagFactory.php new file mode 100644 index 00000000..308deadb --- /dev/null +++ b/database/factories/TagFactory.php @@ -0,0 +1,34 @@ + + */ +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]); + } +} diff --git a/database/migrations/2026_02_23_000001_create_artwork_awards_table.php b/database/migrations/2026_02_23_000001_create_artwork_awards_table.php new file mode 100644 index 00000000..a1d6823d --- /dev/null +++ b/database/migrations/2026_02_23_000001_create_artwork_awards_table.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/database/migrations/2026_02_23_000001_create_user_followers_table.php b/database/migrations/2026_02_23_000001_create_user_followers_table.php new file mode 100644 index 00000000..c87e1421 --- /dev/null +++ b/database/migrations/2026_02_23_000001_create_user_followers_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/database/migrations/2026_02_23_000002_create_artwork_award_stats_table.php b/database/migrations/2026_02_23_000002_create_artwork_award_stats_table.php new file mode 100644 index 00000000..7ad97f53 --- /dev/null +++ b/database/migrations/2026_02_23_000002_create_artwork_award_stats_table.php @@ -0,0 +1,29 @@ +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'); + } +}; diff --git a/database/migrations/2026_02_23_000002_create_profile_comments_table.php b/database/migrations/2026_02_23_000002_create_profile_comments_table.php new file mode 100644 index 00000000..e75f9bc1 --- /dev/null +++ b/database/migrations/2026_02_23_000002_create_profile_comments_table.php @@ -0,0 +1,36 @@ +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'); + } +}; diff --git a/database/migrations/2026_02_23_000003_add_legacy_id_to_artwork_comments_table.php b/database/migrations/2026_02_23_000003_add_legacy_id_to_artwork_comments_table.php new file mode 100644 index 00000000..b4ea7107 --- /dev/null +++ b/database/migrations/2026_02_23_000003_add_legacy_id_to_artwork_comments_table.php @@ -0,0 +1,25 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_02_23_000003_add_profile_views_to_user_statistics_table.php b/database/migrations/2026_02_23_000003_add_profile_views_to_user_statistics_table.php new file mode 100644 index 00000000..c74aed8c --- /dev/null +++ b/database/migrations/2026_02_23_000003_add_profile_views_to_user_statistics_table.php @@ -0,0 +1,25 @@ +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'); + } + }); + } +}; diff --git a/database/old_skinbase_structure.sql b/database/old_skinbase_structure.sql new file mode 100644 index 00000000..69e8c45e --- /dev/null +++ b/database/old_skinbase_structure.sql @@ -0,0 +1,2127 @@ +-- phpMyAdmin SQL Dump +-- version 6.0.0-dev+20251214.0422d4917c +-- https://www.phpmyadmin.net/ +-- +-- Host: localhost:3306 +-- Generation Time: Feb 23, 2026 at 06:05 PM +-- Server version: 8.4.3 +-- PHP Version: 8.4.12 + +SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; +START TRANSACTION; +SET time_zone = "+00:00"; + + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8mb4 */; + +-- +-- Database: `projekti_old_skinbase` +-- + +-- -------------------------------------------------------- + +-- +-- Table structure for table `admin` +-- + +CREATE TABLE `admin` ( + `id` int NOT NULL, + `uname` varchar(30) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `passwd` varchar(30) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `name` mediumtext COLLATE utf8mb4_unicode_ci NOT NULL, + `email` varchar(80) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `priv` int DEFAULT NULL, + `info` mediumtext COLLATE utf8mb4_unicode_ci +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `admin_details` +-- + +CREATE TABLE `admin_details` ( + `uid` int NOT NULL DEFAULT '0', + `picture` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `realname` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `email` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `quote` longtext COLLATE utf8mb4_unicode_ci NOT NULL, + `position` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `description` longtext COLLATE utf8mb4_unicode_ci NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `admin_users` +-- + +CREATE TABLE `admin_users` ( + `id` int UNSIGNED NOT NULL, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `password` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `remember_token` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `app_comment` +-- + +CREATE TABLE `app_comment` ( + `id` int NOT NULL, + `nid` int DEFAULT NULL, + `author` varchar(30) CHARACTER SET utf8mb3 DEFAULT NULL, + `datum` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `tekst` mediumtext CHARACTER SET utf8mb3 +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `app_list` +-- + +CREATE TABLE `app_list` ( + `id` int NOT NULL, + `nid` int DEFAULT NULL, + `developer` tinytext CHARACTER SET latin1, + `fulldesc` text CHARACTER SET latin1, + `dl_link` tinytext CHARACTER SET latin1, + `picture` tinytext CHARACTER SET latin1, + `slo_description` text CHARACTER SET latin1 NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Stand-in structure for view `artworks` +-- (See below for the actual view) +-- +CREATE TABLE `artworks` ( +); + +-- -------------------------------------------------------- + +-- +-- Table structure for table `artworks_categories` +-- + +CREATE TABLE `artworks_categories` ( + `category_id` int NOT NULL, + `category_name` varchar(128) CHARACTER SET utf8mb3 NOT NULL DEFAULT '', + `num_artworks` int NOT NULL DEFAULT '0', + `description` text CHARACTER SET utf8mb3 NOT NULL, + `official_webpage` varchar(120) CHARACTER SET utf8mb3 NOT NULL DEFAULT '', + `picture` tinytext CHARACTER SET utf8mb3 NOT NULL, + `views` int NOT NULL DEFAULT '0', + `section_id` int NOT NULL DEFAULT '0', + `rootid` int NOT NULL DEFAULT '0' +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `artworks_comments` +-- + +CREATE TABLE `artworks_comments` ( + `comment_id` int NOT NULL, + `artwork_id` int NOT NULL DEFAULT '0', + `owner` varchar(32) CHARACTER SET utf8mb3 NOT NULL DEFAULT '', + `author` varchar(80) CHARACTER SET utf8mb3 DEFAULT NULL, + `owner_user_id` int NOT NULL, + `user_id` int NOT NULL, + `date` date DEFAULT NULL, + `time` time DEFAULT NULL, + `description` text CHARACTER SET utf8mb3 +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `artworks_downloads` +-- + +CREATE TABLE `artworks_downloads` ( + `id` bigint NOT NULL, + `artwork_id` bigint DEFAULT NULL, + `time` time DEFAULT NULL, + `date` date DEFAULT NULL, + `ip` varchar(16) CHARACTER SET utf8mb3 NOT NULL, + `user_id` int DEFAULT '0', + `author_user_id` int NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `awards` +-- + +CREATE TABLE `awards` ( + `id` int NOT NULL, + `title` tinytext CHARACTER SET latin1, + `picture` varchar(120) CHARACTER SET latin1 DEFAULT NULL, + `description` text CHARACTER SET latin1 +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `banners` +-- + +CREATE TABLE `banners` ( + `id` int NOT NULL, + `name` tinytext CHARACTER SET latin1, + `link` tinytext CHARACTER SET latin1, + `picture` tinytext CHARACTER SET latin1, + `description` text CHARACTER SET latin1, + `clicks` int DEFAULT NULL, + `out` int DEFAULT NULL, + `levo` int NOT NULL DEFAULT '0', + `desno` int NOT NULL DEFAULT '0', + `views` int NOT NULL DEFAULT '0' +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `blog_articles` +-- + +CREATE TABLE `blog_articles` ( + `id` int NOT NULL, + `headline` varchar(80) CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci NOT NULL DEFAULT '', + `subtitle` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci NOT NULL DEFAULT '', + `author` varchar(80) CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci NOT NULL DEFAULT '', + `user_id` int NOT NULL, + `category` varchar(80) CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci NOT NULL DEFAULT '', + `datum2` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `datum` timestamp NULL DEFAULT NULL, + `tekst` text CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci NOT NULL, + `frontpage` int NOT NULL DEFAULT '1', + `views` int NOT NULL DEFAULT '1', + `type` tinyint NOT NULL DEFAULT '1', + `lang` tinytext CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci NOT NULL, + `allowComments` int NOT NULL DEFAULT '1', + `blog_id` int NOT NULL DEFAULT '0' +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci PACK_KEYS=0; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `blog_comments` +-- + +CREATE TABLE `blog_comments` ( + `id` int NOT NULL, + `nid` int NOT NULL DEFAULT '0', + `author` varchar(30) CHARACTER SET utf8mb3 NOT NULL DEFAULT '', + `email` varchar(64) CHARACTER SET utf8mb3 NOT NULL DEFAULT '', + `datum` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `tekst` text CHARACTER SET utf8mb3 NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `blog_topic` +-- + +CREATE TABLE `blog_topic` ( + `id` int NOT NULL, + `naslov` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci NOT NULL DEFAULT '', + `counter` int DEFAULT NULL, + `author` varchar(32) CHARACTER SET ucs2 COLLATE ucs2_slovenian_ci NOT NULL DEFAULT '0', + `blog_id` int NOT NULL DEFAULT '0', + `zs` int NOT NULL DEFAULT '0' +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `chat` +-- + +CREATE TABLE `chat` ( + `chat_id` int NOT NULL, + `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `user_id` int NOT NULL, + `sender` varchar(80) DEFAULT NULL, + `reciever` varchar(80) DEFAULT NULL, + `message` tinytext +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `chat_ses` +-- + +CREATE TABLE `chat_ses` ( + `time` varchar(14) DEFAULT NULL, + `ip` varchar(20) DEFAULT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `competitions` +-- + +CREATE TABLE `competitions` ( + `id` int NOT NULL, + `ime` varchar(255) NOT NULL DEFAULT '', + `opis` text NOT NULL, + `start_date` date NOT NULL DEFAULT '0000-00-00', + `end_date` date NOT NULL DEFAULT '0000-00-00', + `active` int NOT NULL DEFAULT '1' +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `compvotes` +-- + +CREATE TABLE `compvotes` ( + `id` int NOT NULL, + `skinid` int NOT NULL DEFAULT '0', + `vote` int NOT NULL DEFAULT '0', + `datum` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `uname` tinytext NOT NULL, + `ip` tinytext NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1 COMMENT='Tabela za glasovanje tekmovanj'; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `country` +-- + +CREATE TABLE `country` ( + `id` int NOT NULL, + `name` varchar(80) DEFAULT NULL, + `timezone` tinyint NOT NULL DEFAULT '0', + `flag` tinytext NOT NULL, + `kontinent` tinyint NOT NULL DEFAULT '0', + `num` int NOT NULL DEFAULT '0', + `ISO3661_name` varchar(255) NOT NULL, + `code` char(2) NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `country_list` +-- + +CREATE TABLE `country_list` ( + `country_code` char(2) CHARACTER SET utf8mb3 COLLATE utf8mb3_bin DEFAULT NULL, + `country_name` varchar(45) CHARACTER SET utf8mb3 COLLATE utf8mb3_bin DEFAULT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `country_timezone` +-- + +CREATE TABLE `country_timezone` ( + `zone_id` int NOT NULL, + `abbreviation` varchar(6) CHARACTER SET utf8mb3 COLLATE utf8mb3_bin NOT NULL, + `time_start` int NOT NULL, + `gmt_offset` int NOT NULL, + `dst` char(1) CHARACTER SET utf8mb3 COLLATE utf8mb3_bin NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `country_tz_list` +-- + +CREATE TABLE `country_tz_list` ( + `CountryCode` char(2) NOT NULL, + `Coordinates` char(15) NOT NULL, + `TimeZone` char(32) NOT NULL, + `Comments` varchar(85) NOT NULL, + `UTC offset` char(8) NOT NULL, + `UTC DST offset` char(8) NOT NULL, + `Notes` varchar(79) DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `country_zone` +-- + +CREATE TABLE `country_zone` ( + `zone_id` int NOT NULL, + `country_code` char(2) CHARACTER SET utf8mb3 COLLATE utf8mb3_bin NOT NULL, + `zone_name` varchar(35) CHARACTER SET utf8mb3 COLLATE utf8mb3_bin NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_bin; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `cpad` +-- + +CREATE TABLE `cpad` ( + `keycode` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `favourites` +-- + +CREATE TABLE `favourites` ( + `favourite_id` int NOT NULL, + `artwork_id` int NOT NULL DEFAULT '0', + `datum` date NOT NULL DEFAULT '0000-00-00', + `user_type` int NOT NULL DEFAULT '0', + `author_id` int NOT NULL, + `user_id` int NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `featartist_comments` +-- + +CREATE TABLE `featartist_comments` ( + `id` int NOT NULL, + `wid` int NOT NULL DEFAULT '0', + `author` varchar(30) NOT NULL DEFAULT '', + `datum` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `tekst` text NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `featured_works` +-- + +CREATE TABLE `featured_works` ( + `featured_id` int NOT NULL, + `artwork_id` int DEFAULT NULL, + `added` tinytext, + `description` text, + `post_date` date DEFAULT NULL, + `type` tinyint NOT NULL DEFAULT '0', + `rootid` int NOT NULL DEFAULT '0', + `user_id` int DEFAULT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `feat_app` +-- + +CREATE TABLE `feat_app` ( + `id` int NOT NULL, + `sid` int DEFAULT NULL, + `added` tinytext, + `description` text, + `date` date DEFAULT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `feat_author` +-- + +CREATE TABLE `feat_author` ( + `id` int NOT NULL, + `sid` int DEFAULT NULL, + `added` tinytext, + `description` text, + `date` date DEFAULT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `forum_posts` +-- + +CREATE TABLE `forum_posts` ( + `post_id` int NOT NULL, + `topic_id` int DEFAULT NULL, + `user_id` int NOT NULL, + `updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `isupdated` int NOT NULL DEFAULT '0', + `message` text, + `post_date` datetime NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `forum_topics` +-- + +CREATE TABLE `forum_topics` ( + `topic_id` int NOT NULL, + `root_id` int NOT NULL DEFAULT '0', + `topic` text, + `user_id` int NOT NULL, + `post_date` datetime NOT NULL, + `preview` text NOT NULL, + `discuss` text, + `last_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `open` tinyint NOT NULL DEFAULT '1', + `privilege` int NOT NULL DEFAULT '0', + `views` int NOT NULL DEFAULT '0' +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `forum_topics_gallery` +-- + +CREATE TABLE `forum_topics_gallery` ( + `id` int NOT NULL, + `name` varchar(80) NOT NULL DEFAULT '', + `folder` varchar(80) NOT NULL DEFAULT '', + `datum` datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + `description` text NOT NULL, + `dls` int NOT NULL DEFAULT '0', + `views` int NOT NULL DEFAULT '0', + `rating` float(8,4) NOT NULL DEFAULT '0.0000', + `rating_num` int NOT NULL DEFAULT '0', + `category` int NOT NULL DEFAULT '0', + `format` varchar(4) NOT NULL DEFAULT 'jpg', + `resolution` varchar(10) NOT NULL DEFAULT '', + `filesize` varchar(10) NOT NULL DEFAULT '0', + `active` int NOT NULL DEFAULT '1', + `adult` tinyint(1) NOT NULL DEFAULT '0', + `picture_name` varchar(255) NOT NULL, + `picture_alt` varchar(255) NOT NULL, + `tags` varchar(512) NOT NULL, + `username` varchar(255) NOT NULL, + `email` varchar(255) NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `friends_list` +-- + +CREATE TABLE `friends_list` ( + `id` int NOT NULL, + `date_added` date NOT NULL DEFAULT '0000-00-00', + `friend_id` int NOT NULL, + `user_id` int NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `interviews` +-- + +CREATE TABLE `interviews` ( + `id` int NOT NULL, + `headline` varchar(80) DEFAULT NULL, + `author` varchar(80) DEFAULT NULL, + `username` varchar(80) NOT NULL, + `picture` varchar(120) DEFAULT NULL, + `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `datum` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', + `preview` text, + `tekst` text, + `lang` varchar(10) DEFAULT NULL, + `views` int NOT NULL DEFAULT '0' +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `interviews_comment` +-- + +CREATE TABLE `interviews_comment` ( + `id` int NOT NULL, + `nid` int NOT NULL DEFAULT '0', + `author` varchar(30) NOT NULL DEFAULT '', + `datum` datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + `tekst` text NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `location` +-- + +CREATE TABLE `location` ( + `id` int NOT NULL, + `name` tinytext NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1 COMMENT='Sezname kontinentov'; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `mainads` +-- + +CREATE TABLE `mainads` ( + `id` int NOT NULL, + `name` varchar(255) NOT NULL, + `picture` varchar(255) NOT NULL, + `online` int NOT NULL DEFAULT '0', + `author` varchar(255) NOT NULL, + `od` date NOT NULL, + `do` date NOT NULL, + `link` varchar(255) NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `messages` +-- + +CREATE TABLE `messages` ( + `message_id` int NOT NULL, + `sender_id` int NOT NULL, + `reciever_id` int NOT NULL, + `subject` varchar(255) NOT NULL, + `body` text NOT NULL, + `create_date` int NOT NULL, + `read_date` int NOT NULL DEFAULT '0', + `sender_active` enum('Y','N') NOT NULL DEFAULT 'Y', + `reciever_active` enum('Y','N') NOT NULL DEFAULT 'Y', + `reply_id` int NOT NULL DEFAULT '0' +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `migrations` +-- + +CREATE TABLE `migrations` ( + `id` int UNSIGNED NOT NULL, + `migration` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `batch` int NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `mlist_emails` +-- + +CREATE TABLE `mlist_emails` ( + `id` int NOT NULL, + `email` varchar(255) NOT NULL, + `status` int NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `monthly_downloads` +-- + +CREATE TABLE `monthly_downloads` ( + `fname` int NOT NULL DEFAULT '0', + `mesec` int NOT NULL DEFAULT '0', + `leto` int NOT NULL DEFAULT '0', + `dls` int NOT NULL DEFAULT '0', + `lastDl` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `monthly_downloads_archive` +-- + +CREATE TABLE `monthly_downloads_archive` ( + `fname` int NOT NULL DEFAULT '0', + `mesec` int NOT NULL DEFAULT '0', + `leto` int NOT NULL DEFAULT '0', + `dls` int NOT NULL DEFAULT '0', + `lastDl` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `monthly_wallz` +-- + +CREATE TABLE `monthly_wallz` ( + `id` int NOT NULL, + `sid` int DEFAULT NULL, + `added` tinytext, + `description` text, + `datum` date DEFAULT NULL, + `type` tinyint NOT NULL DEFAULT '0' +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `news` +-- + +CREATE TABLE `news` ( + `news_id` int NOT NULL, + `headline` varchar(80) DEFAULT NULL, + `user_id` int NOT NULL, + `category_id` int DEFAULT NULL, + `create_date` datetime NOT NULL, + `update_date` datetime NOT NULL, + `preview` text, + `content` text, + `picture` varchar(255) NOT NULL, + `lang` varchar(10) DEFAULT NULL, + `views` int NOT NULL DEFAULT '0' +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `news_categories` +-- + +CREATE TABLE `news_categories` ( + `category_id` int NOT NULL, + `category_name` varchar(255) NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `news_comment` +-- + +CREATE TABLE `news_comment` ( + `comment_id` int NOT NULL, + `news_id` int NOT NULL DEFAULT '0', + `author` varchar(30) NOT NULL DEFAULT '', + `user_id` int NOT NULL, + `posted` datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + `message` text NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `news_groups` +-- + +CREATE TABLE `news_groups` ( + `id` int NOT NULL, + `name` tinytext NOT NULL, + `link` tinytext NOT NULL, + `source` tinytext NOT NULL, + `num` int NOT NULL DEFAULT '0', + `rootid` int NOT NULL DEFAULT '0', + `description` text NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `news_logged` +-- + +CREATE TABLE `news_logged` ( + `id` int NOT NULL, + `nid` int NOT NULL DEFAULT '0', + `ip` varchar(250) NOT NULL DEFAULT '' +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `news_rss` +-- + +CREATE TABLE `news_rss` ( + `news_id` int NOT NULL, + `headline` varchar(255) NOT NULL DEFAULT '', + `datum` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `description` text NOT NULL, + `link` tinytext NOT NULL, + `category_id` int NOT NULL DEFAULT '0', + `updated` tinyint(1) NOT NULL DEFAULT '0' +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `notification` +-- + +CREATE TABLE `notification` ( + `notification_id` int NOT NULL, + `event` varchar(255) NOT NULL, + `event_id` int NOT NULL DEFAULT '0', + `new` tinyint(1) NOT NULL DEFAULT '1', + `author_id` int NOT NULL, + `user_id` int NOT NULL, + `post_date` int NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `partners` +-- + +CREATE TABLE `partners` ( + `id` int NOT NULL, + `name` tinytext, + `link` tinytext, + `picture` tinytext, + `description` text, + `clicks` int DEFAULT NULL, + `out_click` int NOT NULL DEFAULT '0', + `main` int DEFAULT NULL, + `gfx` int DEFAULT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `partners_log` +-- + +CREATE TABLE `partners_log` ( + `id` int NOT NULL, + `pid` int NOT NULL DEFAULT '0', + `datum` datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + `ip` tinytext NOT NULL, + `out` tinyint NOT NULL DEFAULT '0' +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `publish_stream` +-- + +CREATE TABLE `publish_stream` ( + `id` int NOT NULL, + `naslov` varchar(255) DEFAULT NULL, + `url` varchar(255) DEFAULT NULL, + `povzetek` varchar(512) DEFAULT NULL, + `slika` varchar(256) DEFAULT NULL, + `opis` text, + `author` int DEFAULT NULL, + `datum` datetime DEFAULT NULL, + `type` int DEFAULT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `rating` +-- + +CREATE TABLE `rating` ( + `id` int NOT NULL, + `sid` int NOT NULL DEFAULT '0', + `datum` datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + `points` int NOT NULL DEFAULT '0', + `ip` tinytext NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `sb_topics` +-- + +CREATE TABLE `sb_topics` ( + `id` int NOT NULL, + `topicname` varchar(20) DEFAULT NULL, + `topicimage` varchar(80) DEFAULT NULL, + `topictext` varchar(255) DEFAULT NULL, + `counter` int DEFAULT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `smileys` +-- + +CREATE TABLE `smileys` ( + `smiley_id` int NOT NULL, + `code` varchar(10) NOT NULL, + `picture` varchar(100) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL, + `emotion` varchar(255) DEFAULT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `users` +-- + +CREATE TABLE `users` ( + `user_id` int NOT NULL, + `uname` varchar(80) DEFAULT NULL, + `password` varchar(80) DEFAULT NULL, + `email` varchar(80) DEFAULT NULL, + `real_name` varchar(255) DEFAULT NULL, + `web` varchar(120) DEFAULT NULL, + `icq` varchar(10) DEFAULT NULL, + `birth` date DEFAULT NULL, + `gender` enum('M','F','X') DEFAULT 'X', + `country` varchar(80) DEFAULT NULL, + `country_code` char(4) DEFAULT NULL, + `lang` varchar(10) DEFAULT NULL, + `mlist` char(1) DEFAULT NULL, + `icon` tinytext, + `LastVisit` datetime DEFAULT NULL, + `NumStats` tinyint NOT NULL DEFAULT '5', + `picture` tinytext, + `signature` tinytext, + `about_me` text, + `zone` int NOT NULL DEFAULT '246', + `numboard` tinyint NOT NULL DEFAULT '5', + `profileviews` int NOT NULL DEFAULT '0', + `eicon` tinytext, + `signatures` tinyint(1) NOT NULL DEFAULT '1', + `subscribe` date DEFAULT NULL, + `user_type` int NOT NULL DEFAULT '0', + `active` int NOT NULL DEFAULT '0', + `joinDate` datetime DEFAULT NULL, + `authorization_id` varchar(24) NOT NULL DEFAULT '', + `authorized` tinyint(1) NOT NULL DEFAULT '0', + `lastactivity` int DEFAULT NULL, + `cover_art` varchar(255) DEFAULT NULL, + `friend_upload_notice` tinyint(1) NOT NULL DEFAULT '1', + `description` text, + `password2` varchar(255) DEFAULT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `users_comments` +-- + +CREATE TABLE `users_comments` ( + `comment_id` int NOT NULL, + `artist_id` int NOT NULL, + `user_id` int NOT NULL, + `date` date DEFAULT NULL, + `time` time DEFAULT NULL, + `description` text, + `post_date` int NOT NULL, + `active` tinyint(1) NOT NULL DEFAULT '1' +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `users_data` +-- + +CREATE TABLE `users_data` ( + `user_id` int NOT NULL, + `web` varchar(120) DEFAULT NULL, + `icq` varchar(10) DEFAULT NULL, + `birth` date DEFAULT NULL, + `gender` char(2) DEFAULT NULL, + `country` varchar(80) DEFAULT NULL, + `country_code` char(4) NOT NULL, + `lang` varchar(10) DEFAULT NULL, + `mlist` char(1) DEFAULT NULL, + `icon` tinytext, + `menu` tinytext NOT NULL, + `LastVisit` datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + `NumStats` tinyint NOT NULL DEFAULT '5', + `picture` tinytext NOT NULL, + `signature` tinytext NOT NULL, + `addinfo` text NOT NULL, + `zone` int NOT NULL DEFAULT '246', + `numskin` tinyint NOT NULL DEFAULT '5', + `section_style` tinyint NOT NULL DEFAULT '0', + `numboard` tinyint NOT NULL DEFAULT '5', + `profileviews` int NOT NULL DEFAULT '0', + `eicon` tinytext NOT NULL, + `signatures` tinyint NOT NULL DEFAULT '1', + `subscribe` date NOT NULL DEFAULT '0000-00-00', + `user_type` int NOT NULL DEFAULT '0', + `active` int NOT NULL DEFAULT '1', + `joinDate` date NOT NULL DEFAULT '0000-00-00', + `authorization_id` varchar(24) NOT NULL DEFAULT '', + `authorized` tinyint(1) NOT NULL DEFAULT '0', + `lastactivity` int NOT NULL, + `cover_art` varchar(255) NOT NULL, + `friend_upload_notice` tinyint(1) NOT NULL DEFAULT '1', + `description` text NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `users_login` +-- + +CREATE TABLE `users_login` ( + `user_id` int NOT NULL, + `username` varchar(80) NOT NULL, + `password` varchar(80) NOT NULL, + `email` varchar(80) NOT NULL, + `LastVisit` datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + `user_type` tinyint NOT NULL DEFAULT '0', + `active` tinyint(1) NOT NULL DEFAULT '1', + `join_date` date NOT NULL DEFAULT '0000-00-00', + `authorization_id` varchar(32) NOT NULL DEFAULT '', + `authorization_status` tinyint(1) NOT NULL DEFAULT '0' +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `users_opinions` +-- + +CREATE TABLE `users_opinions` ( + `opinion_id` int NOT NULL, + `artwork_id` int NOT NULL DEFAULT '0', + `score` int NOT NULL DEFAULT '0', + `post_date` date NOT NULL, + `author_id` int NOT NULL, + `user_id` int NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `users_statistics` +-- + +CREATE TABLE `users_statistics` ( + `user_id` int NOT NULL DEFAULT '0', + `uploads` int NOT NULL DEFAULT '0', + `rate` int NOT NULL DEFAULT '0', + `news` int NOT NULL DEFAULT '0', + `weeklypoll` int NOT NULL DEFAULT '0', + `chat` int NOT NULL DEFAULT '0', + `pageviews` int NOT NULL DEFAULT '0', + `msgboard` int NOT NULL DEFAULT '0', + `downloads` int NOT NULL DEFAULT '0', + `reffered` int NOT NULL DEFAULT '0', + `newscomment` int NOT NULL DEFAULT '0', + `awards` int NOT NULL DEFAULT '0', + `refer` int NOT NULL DEFAULT '0', + `zoom` int NOT NULL DEFAULT '0', + `cards` int NOT NULL DEFAULT '0' +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `users_types` +-- + +CREATE TABLE `users_types` ( + `id` int NOT NULL, + `ime` varchar(64) NOT NULL DEFAULT '', + `opis` text NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `user_weeka` +-- + +CREATE TABLE `user_weeka` ( + `id` int NOT NULL, + `wid` int DEFAULT NULL, + `answer` int DEFAULT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `user_weekq` +-- + +CREATE TABLE `user_weekq` ( + `id` int NOT NULL, + `question` text, + `answers` varchar(255) DEFAULT NULL, + `author` varchar(120) DEFAULT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `user_wpcomments` +-- + +CREATE TABLE `user_wpcomments` ( + `id` int NOT NULL, + `wid` int NOT NULL DEFAULT '0', + `author` varchar(30) NOT NULL DEFAULT '', + `datum` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `tekst` text NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=latin1; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `wallz` +-- + +CREATE TABLE `wallz` ( + `id` int NOT NULL, + `name` varchar(80) CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci DEFAULT NULL, + `uname` varchar(80) CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci DEFAULT NULL, + `user_id` int NOT NULL, + `fname` varchar(80) CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci DEFAULT NULL, + `section` varchar(80) CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci DEFAULT NULL, + `category` int NOT NULL DEFAULT '0', + `picture` varchar(120) CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci DEFAULT NULL, + `datum` datetime DEFAULT NULL, + `updated` datetime DEFAULT NULL, + `description` text CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci, + `dls` int DEFAULT '0', + `subcat` varchar(80) CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci NOT NULL DEFAULT '0', + `views` int DEFAULT '0', + `zoom` int DEFAULT '0', + `rating` float(8,4) NOT NULL DEFAULT '0.0000', + `rating_num` int NOT NULL DEFAULT '0', + `cards` int NOT NULL DEFAULT '0', + `contest` int NOT NULL DEFAULT '0', + `slo_name` varchar(128) CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci DEFAULT NULL, + `slo_description` text CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci, + `slo_translation` varchar(20) CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci DEFAULT NULL, + `lang_type` int NOT NULL DEFAULT '0', + `approved` tinyint(1) NOT NULL DEFAULT '0', + `competition_id` int NOT NULL DEFAULT '0', + `rootid` int NOT NULL DEFAULT '0', + `rootsubid` int NOT NULL DEFAULT '0', + `width` int NOT NULL, + `height` int NOT NULL, + `user_ip` varchar(32) CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci NOT NULL, + `public` enum('Y','N') CHARACTER SET utf8mb3 COLLATE utf8mb3_slovenian_ci NOT NULL DEFAULT 'Y' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `wallz_lenght` +-- + +CREATE TABLE `wallz_lenght` ( + `id` int NOT NULL, + `sid` int NOT NULL DEFAULT '0', + `lenght` int NOT NULL DEFAULT '0' +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `weeka` +-- + +CREATE TABLE `weeka` ( + `id` int NOT NULL, + `wid` int DEFAULT NULL, + `answer` int DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `weekq` +-- + +CREATE TABLE `weekq` ( + `id` int NOT NULL, + `question` text, + `answers` varchar(255) DEFAULT NULL, + `author` varchar(120) DEFAULT NULL, + `datum` date NOT NULL DEFAULT '0000-00-00' +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `wp_comments` +-- + +CREATE TABLE `wp_comments` ( + `id` int NOT NULL, + `wid` int NOT NULL DEFAULT '0', + `author` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `datum` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `tekst` mediumtext COLLATE utf8mb4_unicode_ci NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- +-- Indexes for dumped tables +-- + +-- +-- Indexes for table `admin` +-- +ALTER TABLE `admin` + ADD PRIMARY KEY (`id`); + +-- +-- Indexes for table `admin_details` +-- +ALTER TABLE `admin_details` + ADD PRIMARY KEY (`uid`); + +-- +-- Indexes for table `admin_users` +-- +ALTER TABLE `admin_users` + ADD PRIMARY KEY (`id`), + ADD UNIQUE KEY `users_email_unique` (`email`); + +-- +-- Indexes for table `app_comment` +-- +ALTER TABLE `app_comment` + ADD PRIMARY KEY (`id`), + ADD KEY `nid` (`nid`), + ADD KEY `author` (`author`), + ADD KEY `datum` (`datum`); + +-- +-- Indexes for table `app_list` +-- +ALTER TABLE `app_list` + ADD PRIMARY KEY (`id`), + ADD KEY `nid` (`nid`); + +-- +-- Indexes for table `artworks_categories` +-- +ALTER TABLE `artworks_categories` + ADD PRIMARY KEY (`category_id`), + ADD KEY `sname` (`category_name`), + ADD KEY `num` (`num_artworks`), + ADD KEY `views` (`views`), + ADD KEY `rootid` (`section_id`,`rootid`), + ADD KEY `idx_section-id_rootid` (`section_id`,`rootid`) USING BTREE; + +-- +-- Indexes for table `artworks_comments` +-- +ALTER TABLE `artworks_comments` + ADD PRIMARY KEY (`comment_id`), + ADD KEY `author` (`author`), + ADD KEY `date` (`date`), + ADD KEY `name` (`artwork_id`), + ADD KEY `idx_coments_user_id` (`user_id`), + ADD KEY `idx_coments_owner_user_id` (`owner_user_id`), + ADD KEY `idx_date_time` (`date`,`time`); + +-- +-- Indexes for table `artworks_downloads` +-- +ALTER TABLE `artworks_downloads` + ADD PRIMARY KEY (`id`), + ADD KEY `fname` (`artwork_id`), + ADD KEY `date` (`date`), + ADD KEY `idx_id_date` (`ip`,`date`), + ADD KEY `idx_ip_date_fname` (`ip`,`date`,`artwork_id`), + ADD KEY `idx_date_artwork_id` (`artwork_id`,`date`), + ADD KEY `author_user_id` (`author_user_id`), + ADD KEY `idx_artwork_id` (`artwork_id`), + ADD KEY `idx_id` (`id`); + +-- +-- Indexes for table `awards` +-- +ALTER TABLE `awards` + ADD PRIMARY KEY (`id`); + +-- +-- Indexes for table `banners` +-- +ALTER TABLE `banners` + ADD PRIMARY KEY (`id`); + +-- +-- Indexes for table `blog_articles` +-- +ALTER TABLE `blog_articles` + ADD PRIMARY KEY (`id`), + ADD KEY `frontpage` (`frontpage`,`views`,`type`), + ADD KEY `datum` (`datum`), + ADD KEY `subtitle` (`subtitle`), + ADD KEY `user_id` (`user_id`); +ALTER TABLE `blog_articles` ADD FULLTEXT KEY `headline` (`headline`,`subtitle`,`tekst`); + +-- +-- Indexes for table `blog_comments` +-- +ALTER TABLE `blog_comments` + ADD UNIQUE KEY `id` (`id`); + +-- +-- Indexes for table `blog_topic` +-- +ALTER TABLE `blog_topic` + ADD PRIMARY KEY (`id`), + ADD KEY `blog_id` (`blog_id`), + ADD KEY `zs` (`zs`), + ADD KEY `author` (`author`); + +-- +-- Indexes for table `chat` +-- +ALTER TABLE `chat` + ADD PRIMARY KEY (`chat_id`); + +-- +-- Indexes for table `competitions` +-- +ALTER TABLE `competitions` + ADD KEY `id` (`id`); + +-- +-- Indexes for table `compvotes` +-- +ALTER TABLE `compvotes` + ADD PRIMARY KEY (`id`); + +-- +-- Indexes for table `country` +-- +ALTER TABLE `country` + ADD PRIMARY KEY (`id`), + ADD KEY `timezone` (`timezone`), + ADD KEY `name` (`name`), + ADD KEY `code` (`code`); + +-- +-- Indexes for table `country_list` +-- +ALTER TABLE `country_list` + ADD KEY `idx_country_code` (`country_code`), + ADD KEY `country_name` (`country_name`); + +-- +-- Indexes for table `country_timezone` +-- +ALTER TABLE `country_timezone` + ADD KEY `idx_zone_id` (`zone_id`), + ADD KEY `idx_time_start` (`time_start`); + +-- +-- Indexes for table `country_tz_list` +-- +ALTER TABLE `country_tz_list` + ADD PRIMARY KEY (`TimeZone`); + +-- +-- Indexes for table `country_zone` +-- +ALTER TABLE `country_zone` + ADD PRIMARY KEY (`zone_id`), + ADD KEY `idx_zone_name` (`zone_name`); + +-- +-- Indexes for table `cpad` +-- +ALTER TABLE `cpad` + ADD UNIQUE KEY `cpad_keycode_unique` (`keycode`); + +-- +-- Indexes for table `favourites` +-- +ALTER TABLE `favourites` + ADD PRIMARY KEY (`favourite_id`), + ADD KEY `skinid` (`artwork_id`,`datum`), + ADD KEY `datum` (`datum`); + +-- +-- Indexes for table `featartist_comments` +-- +ALTER TABLE `featartist_comments` + ADD UNIQUE KEY `id` (`id`), + ADD KEY `wid` (`wid`); + +-- +-- Indexes for table `featured_works` +-- +ALTER TABLE `featured_works` + ADD PRIMARY KEY (`featured_id`), + ADD KEY `sid` (`artwork_id`), + ADD KEY `date` (`post_date`), + ADD KEY `idx_rootid_artworkid` (`featured_id`,`artwork_id`); + +-- +-- Indexes for table `feat_app` +-- +ALTER TABLE `feat_app` + ADD PRIMARY KEY (`id`), + ADD KEY `sid` (`sid`), + ADD KEY `date` (`date`); + +-- +-- Indexes for table `feat_author` +-- +ALTER TABLE `feat_author` + ADD PRIMARY KEY (`id`), + ADD KEY `sid` (`sid`), + ADD KEY `date` (`date`); + +-- +-- Indexes for table `forum_posts` +-- +ALTER TABLE `forum_posts` + ADD PRIMARY KEY (`post_id`), + ADD KEY `tid` (`topic_id`), + ADD KEY `post_date` (`post_date`), + ADD KEY `user_id` (`user_id`); + +-- +-- Indexes for table `forum_topics` +-- +ALTER TABLE `forum_topics` + ADD PRIMARY KEY (`topic_id`), + ADD KEY `laste` (`last_update`), + ADD KEY `root_id` (`root_id`), + ADD KEY `privilege` (`privilege`), + ADD KEY `open` (`open`), + ADD KEY `views` (`views`); + +-- +-- Indexes for table `forum_topics_gallery` +-- +ALTER TABLE `forum_topics_gallery` + ADD PRIMARY KEY (`id`), + ADD KEY `name` (`name`), + ADD KEY `date` (`datum`), + ADD KEY `fname` (`folder`), + ADD KEY `dls` (`dls`), + ADD KEY `views` (`views`), + ADD KEY `rating` (`rating`), + ADD KEY `category` (`category`), + ADD KEY `tags` (`tags`(333)), + ADD KEY `username` (`username`), + ADD KEY `active` (`active`), + ADD KEY `adult` (`adult`); + +-- +-- Indexes for table `friends_list` +-- +ALTER TABLE `friends_list` + ADD PRIMARY KEY (`id`), + ADD KEY `datum` (`date_added`), + ADD KEY `friend_id` (`friend_id`), + ADD KEY `user_id` (`user_id`); + +-- +-- Indexes for table `interviews` +-- +ALTER TABLE `interviews` + ADD PRIMARY KEY (`id`), + ADD KEY `datum` (`datum`), + ADD KEY `headline` (`headline`); + +-- +-- Indexes for table `interviews_comment` +-- +ALTER TABLE `interviews_comment` + ADD UNIQUE KEY `id` (`id`), + ADD KEY `nid` (`nid`); + +-- +-- Indexes for table `location` +-- +ALTER TABLE `location` + ADD PRIMARY KEY (`id`); + +-- +-- Indexes for table `mainads` +-- +ALTER TABLE `mainads` + ADD PRIMARY KEY (`id`), + ADD KEY `od` (`od`), + ADD KEY `do` (`do`); + +-- +-- Indexes for table `messages` +-- +ALTER TABLE `messages` + ADD PRIMARY KEY (`message_id`); + +-- +-- Indexes for table `migrations` +-- +ALTER TABLE `migrations` + ADD PRIMARY KEY (`id`); + +-- +-- Indexes for table `mlist_emails` +-- +ALTER TABLE `mlist_emails` + ADD PRIMARY KEY (`id`); + +-- +-- Indexes for table `monthly_downloads` +-- +ALTER TABLE `monthly_downloads` + ADD KEY `fname` (`fname`,`mesec`,`leto`,`dls`); + +-- +-- Indexes for table `monthly_downloads_archive` +-- +ALTER TABLE `monthly_downloads_archive` + ADD KEY `fname` (`fname`,`mesec`,`leto`,`dls`); + +-- +-- Indexes for table `monthly_wallz` +-- +ALTER TABLE `monthly_wallz` + ADD PRIMARY KEY (`id`), + ADD KEY `sid` (`sid`), + ADD KEY `date` (`datum`); + +-- +-- Indexes for table `news` +-- +ALTER TABLE `news` + ADD PRIMARY KEY (`news_id`), + ADD KEY `datum` (`update_date`), + ADD KEY `headline` (`headline`), + ADD KEY `user_id` (`user_id`); + +-- +-- Indexes for table `news_categories` +-- +ALTER TABLE `news_categories` + ADD PRIMARY KEY (`category_id`); + +-- +-- Indexes for table `news_comment` +-- +ALTER TABLE `news_comment` + ADD UNIQUE KEY `id` (`comment_id`), + ADD KEY `user_id` (`user_id`), + ADD KEY `news_Id` (`news_id`), + ADD KEY `posted` (`posted`); + +-- +-- Indexes for table `news_groups` +-- +ALTER TABLE `news_groups` + ADD PRIMARY KEY (`id`); + +-- +-- Indexes for table `news_logged` +-- +ALTER TABLE `news_logged` + ADD PRIMARY KEY (`id`), + ADD KEY `nid` (`nid`), + ADD KEY `ip` (`ip`); + +-- +-- Indexes for table `news_rss` +-- +ALTER TABLE `news_rss` + ADD PRIMARY KEY (`news_id`), + ADD KEY `category` (`category_id`), + ADD KEY `datum` (`datum`), + ADD KEY `headline` (`headline`); +ALTER TABLE `news_rss` ADD FULLTEXT KEY `description` (`description`); + +-- +-- Indexes for table `notification` +-- +ALTER TABLE `notification` + ADD PRIMARY KEY (`notification_id`), + ADD KEY `uname` (`new`); + +-- +-- Indexes for table `partners` +-- +ALTER TABLE `partners` + ADD PRIMARY KEY (`id`); + +-- +-- Indexes for table `partners_log` +-- +ALTER TABLE `partners_log` + ADD PRIMARY KEY (`id`); + +-- +-- Indexes for table `publish_stream` +-- +ALTER TABLE `publish_stream` + ADD PRIMARY KEY (`id`); + +-- +-- Indexes for table `rating` +-- +ALTER TABLE `rating` + ADD PRIMARY KEY (`id`), + ADD KEY `sid` (`sid`), + ADD KEY `datum` (`datum`); + +-- +-- Indexes for table `sb_topics` +-- +ALTER TABLE `sb_topics` + ADD PRIMARY KEY (`id`), + ADD KEY `topicname` (`topicname`); + +-- +-- Indexes for table `smileys` +-- +ALTER TABLE `smileys` + ADD PRIMARY KEY (`smiley_id`), + ADD UNIQUE KEY `code_UNIQUE` (`code`), + ADD KEY `code` (`code`), + ADD KEY `smile` (`picture`); + +-- +-- Indexes for table `users` +-- +ALTER TABLE `users` + ADD PRIMARY KEY (`user_id`), + ADD KEY `uname` (`uname`), + ADD KEY `passwd` (`password`), + ADD KEY `user_type` (`user_type`,`active`), + ADD KEY `authorized` (`authorized`), + ADD KEY `lastactivity` (`lastactivity`), + ADD KEY `country_code` (`country_code`), + ADD KEY `friend_upload_notice` (`friend_upload_notice`), + ADD KEY `idx_user_id` (`user_id`); + +-- +-- Indexes for table `users_comments` +-- +ALTER TABLE `users_comments` + ADD PRIMARY KEY (`comment_id`); + +-- +-- Indexes for table `users_data` +-- +ALTER TABLE `users_data` + ADD UNIQUE KEY `user_id` (`user_id`), + ADD KEY `user_type` (`user_type`,`active`), + ADD KEY `authorized` (`authorized`), + ADD KEY `lastactivity` (`lastactivity`), + ADD KEY `country_code` (`country_code`); + +-- +-- Indexes for table `users_login` +-- +ALTER TABLE `users_login` + ADD PRIMARY KEY (`user_id`), + ADD KEY `uname` (`username`), + ADD KEY `passwd` (`password`), + ADD KEY `user_type` (`user_type`,`active`), + ADD KEY `authorized` (`authorization_status`); + +-- +-- Indexes for table `users_opinions` +-- +ALTER TABLE `users_opinions` + ADD PRIMARY KEY (`opinion_id`), + ADD KEY `sid` (`artwork_id`); + +-- +-- Indexes for table `users_statistics` +-- +ALTER TABLE `users_statistics` + ADD UNIQUE KEY `user_id` (`user_id`), + ADD KEY `uploads` (`uploads`), + ADD KEY `rate` (`rate`), + ADD KEY `downloads` (`downloads`), + ADD KEY `pageviews` (`pageviews`); + +-- +-- Indexes for table `users_types` +-- +ALTER TABLE `users_types` + ADD PRIMARY KEY (`id`); + +-- +-- Indexes for table `user_weeka` +-- +ALTER TABLE `user_weeka` + ADD PRIMARY KEY (`id`), + ADD KEY `wid` (`wid`); + +-- +-- Indexes for table `user_weekq` +-- +ALTER TABLE `user_weekq` + ADD PRIMARY KEY (`id`); + +-- +-- Indexes for table `user_wpcomments` +-- +ALTER TABLE `user_wpcomments` + ADD UNIQUE KEY `id` (`id`), + ADD KEY `wid` (`wid`); + +-- +-- Indexes for table `wallz` +-- +ALTER TABLE `wallz` + ADD PRIMARY KEY (`id`), + ADD KEY `name` (`name`), + ADD KEY `section` (`section`), + ADD KEY `date` (`datum`), + ADD KEY `subcat` (`subcat`), + ADD KEY `zoom` (`zoom`), + ADD KEY `uname` (`uname`), + ADD KEY `fname` (`fname`), + ADD KEY `dls` (`dls`), + ADD KEY `rating` (`rating`), + ADD KEY `lang_type` (`lang_type`), + ADD KEY `competition_id` (`competition_id`), + ADD KEY `category` (`category`), + ADD KEY `approved` (`approved`), + ADD KEY `rootid` (`rootid`), + ADD KEY `rootsubid` (`rootsubid`), + ADD KEY `user_id` (`user_id`), + ADD KEY `width` (`width`,`height`), + ADD KEY `idx_id_approved` (`id`,`approved`), + ADD KEY `public` (`public`), + ADD KEY `idx_id` (`id`), + ADD KEY `idx_approved` (`approved`), + ADD KEY `idx_user_id` (`user_id`); +ALTER TABLE `wallz` ADD FULLTEXT KEY `description` (`description`); + +-- +-- Indexes for table `wallz_lenght` +-- +ALTER TABLE `wallz_lenght` + ADD PRIMARY KEY (`id`), + ADD KEY `sid` (`sid`,`lenght`); + +-- +-- Indexes for table `weeka` +-- +ALTER TABLE `weeka` + ADD PRIMARY KEY (`id`), + ADD KEY `wid` (`wid`), + ADD KEY `answer` (`answer`); + +-- +-- Indexes for table `weekq` +-- +ALTER TABLE `weekq` + ADD PRIMARY KEY (`id`); + +-- +-- Indexes for table `wp_comments` +-- +ALTER TABLE `wp_comments` + ADD UNIQUE KEY `id` (`id`), + ADD KEY `wid` (`wid`); + +-- +-- AUTO_INCREMENT for dumped tables +-- + +-- +-- AUTO_INCREMENT for table `admin` +-- +ALTER TABLE `admin` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=32; + +-- +-- AUTO_INCREMENT for table `admin_users` +-- +ALTER TABLE `admin_users` + MODIFY `id` int UNSIGNED NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=3; + +-- +-- AUTO_INCREMENT for table `app_comment` +-- +ALTER TABLE `app_comment` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=498; + +-- +-- AUTO_INCREMENT for table `app_list` +-- +ALTER TABLE `app_list` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=330; + +-- +-- AUTO_INCREMENT for table `artworks_categories` +-- +ALTER TABLE `artworks_categories` + MODIFY `category_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=568; + +-- +-- AUTO_INCREMENT for table `artworks_comments` +-- +ALTER TABLE `artworks_comments` + MODIFY `comment_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=252715; + +-- +-- AUTO_INCREMENT for table `artworks_downloads` +-- +ALTER TABLE `artworks_downloads` + MODIFY `id` bigint NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `awards` +-- +ALTER TABLE `awards` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=4; + +-- +-- AUTO_INCREMENT for table `banners` +-- +ALTER TABLE `banners` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=18; + +-- +-- AUTO_INCREMENT for table `blog_articles` +-- +ALTER TABLE `blog_articles` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=10; + +-- +-- AUTO_INCREMENT for table `blog_comments` +-- +ALTER TABLE `blog_comments` + MODIFY `id` int NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `blog_topic` +-- +ALTER TABLE `blog_topic` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=11; + +-- +-- AUTO_INCREMENT for table `chat` +-- +ALTER TABLE `chat` + MODIFY `chat_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=71497; + +-- +-- AUTO_INCREMENT for table `competitions` +-- +ALTER TABLE `competitions` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=5; + +-- +-- AUTO_INCREMENT for table `compvotes` +-- +ALTER TABLE `compvotes` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2; + +-- +-- AUTO_INCREMENT for table `country` +-- +ALTER TABLE `country` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=228; + +-- +-- AUTO_INCREMENT for table `country_zone` +-- +ALTER TABLE `country_zone` + MODIFY `zone_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=417; + +-- +-- AUTO_INCREMENT for table `favourites` +-- +ALTER TABLE `favourites` + MODIFY `favourite_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=37503; + +-- +-- AUTO_INCREMENT for table `featartist_comments` +-- +ALTER TABLE `featartist_comments` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=124; + +-- +-- AUTO_INCREMENT for table `featured_works` +-- +ALTER TABLE `featured_works` + MODIFY `featured_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=11853; + +-- +-- AUTO_INCREMENT for table `feat_app` +-- +ALTER TABLE `feat_app` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=51; + +-- +-- AUTO_INCREMENT for table `feat_author` +-- +ALTER TABLE `feat_author` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=19; + +-- +-- AUTO_INCREMENT for table `forum_posts` +-- +ALTER TABLE `forum_posts` + MODIFY `post_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=35407; + +-- +-- AUTO_INCREMENT for table `forum_topics` +-- +ALTER TABLE `forum_topics` + MODIFY `topic_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2940; + +-- +-- AUTO_INCREMENT for table `forum_topics_gallery` +-- +ALTER TABLE `forum_topics_gallery` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=45; + +-- +-- AUTO_INCREMENT for table `friends_list` +-- +ALTER TABLE `friends_list` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2192; + +-- +-- AUTO_INCREMENT for table `interviews` +-- +ALTER TABLE `interviews` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=20; + +-- +-- AUTO_INCREMENT for table `interviews_comment` +-- +ALTER TABLE `interviews_comment` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=72; + +-- +-- AUTO_INCREMENT for table `location` +-- +ALTER TABLE `location` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=8; + +-- +-- AUTO_INCREMENT for table `mainads` +-- +ALTER TABLE `mainads` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=9; + +-- +-- AUTO_INCREMENT for table `messages` +-- +ALTER TABLE `messages` + MODIFY `message_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=7863; + +-- +-- AUTO_INCREMENT for table `migrations` +-- +ALTER TABLE `migrations` + MODIFY `id` int UNSIGNED NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `mlist_emails` +-- +ALTER TABLE `mlist_emails` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=96141; + +-- +-- AUTO_INCREMENT for table `monthly_wallz` +-- +ALTER TABLE `monthly_wallz` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=23; + +-- +-- AUTO_INCREMENT for table `news` +-- +ALTER TABLE `news` + MODIFY `news_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1498; + +-- +-- AUTO_INCREMENT for table `news_categories` +-- +ALTER TABLE `news_categories` + MODIFY `category_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=40; + +-- +-- AUTO_INCREMENT for table `news_comment` +-- +ALTER TABLE `news_comment` + MODIFY `comment_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=3103; + +-- +-- AUTO_INCREMENT for table `news_groups` +-- +ALTER TABLE `news_groups` + MODIFY `id` int NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `news_logged` +-- +ALTER TABLE `news_logged` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=14; + +-- +-- AUTO_INCREMENT for table `news_rss` +-- +ALTER TABLE `news_rss` + MODIFY `news_id` int NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `notification` +-- +ALTER TABLE `notification` + MODIFY `notification_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=288500; + +-- +-- AUTO_INCREMENT for table `partners` +-- +ALTER TABLE `partners` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=111; + +-- +-- AUTO_INCREMENT for table `partners_log` +-- +ALTER TABLE `partners_log` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=15190; + +-- +-- AUTO_INCREMENT for table `publish_stream` +-- +ALTER TABLE `publish_stream` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=6446; + +-- +-- AUTO_INCREMENT for table `rating` +-- +ALTER TABLE `rating` + MODIFY `id` int NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT for table `sb_topics` +-- +ALTER TABLE `sb_topics` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=39; + +-- +-- AUTO_INCREMENT for table `smileys` +-- +ALTER TABLE `smileys` + MODIFY `smiley_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=100; + +-- +-- AUTO_INCREMENT for table `users` +-- +ALTER TABLE `users` + MODIFY `user_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=265854; + +-- +-- AUTO_INCREMENT for table `users_comments` +-- +ALTER TABLE `users_comments` + MODIFY `comment_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2134; + +-- +-- AUTO_INCREMENT for table `users_login` +-- +ALTER TABLE `users_login` + MODIFY `user_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=147293; + +-- +-- AUTO_INCREMENT for table `users_opinions` +-- +ALTER TABLE `users_opinions` + MODIFY `opinion_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=51695; + +-- +-- AUTO_INCREMENT for table `users_types` +-- +ALTER TABLE `users_types` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=100; + +-- +-- AUTO_INCREMENT for table `user_weeka` +-- +ALTER TABLE `user_weeka` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=12; + +-- +-- AUTO_INCREMENT for table `user_weekq` +-- +ALTER TABLE `user_weekq` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=6; + +-- +-- AUTO_INCREMENT for table `user_wpcomments` +-- +ALTER TABLE `user_wpcomments` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=4; + +-- +-- AUTO_INCREMENT for table `wallz` +-- +ALTER TABLE `wallz` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=69525; + +-- +-- AUTO_INCREMENT for table `wallz_lenght` +-- +ALTER TABLE `wallz_lenght` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=20435; + +-- +-- AUTO_INCREMENT for table `weeka` +-- +ALTER TABLE `weeka` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=60635; + +-- +-- AUTO_INCREMENT for table `weekq` +-- +ALTER TABLE `weekq` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=166; + +-- +-- AUTO_INCREMENT for table `wp_comments` +-- +ALTER TABLE `wp_comments` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2025; + +-- -------------------------------------------------------- + +-- +-- Structure for view `artworks` +-- +DROP TABLE IF EXISTS `artworks`; + +CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`127.0.0.1` SQL SECURITY DEFINER VIEW `artworks` AS SELECT `wallz`.`id` AS `id`, `wallz`.`name` AS `name`, `wallz`.`uname` AS `uname`, `wallz`.`user_id` AS `user_id`, `wallz`.`fname` AS `fname`, `wallz`.`section` AS `section`, `wallz`.`category` AS `category`, `wallz`.`picture` AS `picture`, `wallz`.`datum` AS `datum`, `wallz`.`updated` AS `updated`, `wallz`.`description` AS `description`, `wallz`.`dls` AS `dls`, `wallz`.`subcat` AS `subcat`, `wallz`.`views` AS `views`, `wallz`.`zoom` AS `zoom`, `wallz`.`rating` AS `rating`, `wallz`.`rating_num` AS `rating_num`, `wallz`.`cards` AS `cards`, `wallz`.`contest` AS `contest`, `wallz`.`slo_name` AS `slo_name`, `wallz`.`slo_description` AS `slo_description`, `wallz`.`slo_translation` AS `slo_translation`, `wallz`.`lang_type` AS `lang_type`, `wallz`.`approved` AS `approved`, `wallz`.`competition_id` AS `competition_id`, `wallz`.`rootid` AS `rootid`, `wallz`.`rootsubid` AS `rootsubid`, `wallz`.`width` AS `width`, `wallz`.`height` AS `height`, `wallz`.`user_ip` AS `user_ip` FROM `wallz` ; +COMMIT; + +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; diff --git a/docs/tags-system.md b/docs/tags-system.md new file mode 100644 index 00000000..7f6ce368 --- /dev/null +++ b/docs/tags-system.md @@ -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 + 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: +- `` → `{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 +``` diff --git a/package.json b/package.json index dca569ab..38bdc235 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/phpunit.xml b/phpunit.xml index d7032415..bf94f051 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -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> diff --git a/playwright-report/index.html b/playwright-report/index.html new file mode 100644 index 00000000..1e0b9d82 --- /dev/null +++ b/playwright-report/index.html @@ -0,0 +1,85 @@ + + +<!DOCTYPE html> +<html style='scrollbar-gutter: stable both-edges;'> + <head> + <meta charset='UTF-8'> + <meta name='color-scheme' content='dark light'> + <meta name='viewport' content='width=device-width, initial-scale=1.0'> + <title>Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts index 1348c242..2d16d302 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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'] }, + }, + */ ], }); diff --git a/public/gfx/sb_join.jpg b/public/gfx/sb_join.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3529d1c1af43463395911f2a0a3c73ac83538a24 GIT binary patch literal 12679 zcmd6NWmw!ju2a%tc9 zoO{l5pXWa3e!Ka7NH*Eb{F6*3Gm|{dKdu3=U&<-U0T2)n018h4JT3yH0Z2$lD9Fel zAP|U(3PMH0K}UP`>=_;yj0VQX!N34 zKSO(l`iBSt7z+y@7oVAskXc=jQ&9c?IX$)ma8Low2oNL$8~`E?0um0wV>f^TfB-;1 z`SZE|nUD|=kx_sk)Mro4D%b!-1SCWxWE2n(0~rMc06;)Q0wCj{-~#bzK=`y=5VbeX z33S}Aje`<9Q3(j8k|v1hd8K7cTypcOI;PZJgMSe*@R+T;g;v*0KG7q6VuXnFUzj}+ zM0p|)d}?FCd1?bZ@jyg;;_){(I&2nS$1kwC;j!U0GC z9^Sy9%=kE>6MbjdS}V>FD!&kT(F#8J22`io%1m*JWs~Ct71^Sk5&s4A>=!HXUzhwD z@2h^-8MI1$`Q_?8(l^M_o1bW)|3AA0%VY?N+m`yqS~2_?CK?K2aOg$|QICLrifU=D zeA972C4iA8QUFxj?!tJ%RPuHj!Oa9m4{v#4UO8 z{~c=(#Fp6zaz1Xk=ZUgm_3=8-&i_-~@*_YQc>Kf_%FMNWbKw67*oWwk^GJ>$@nZaE z2I)9Sqk{ihkEMK-Ch$6D2w5AM!@23>xkf)VWkI}Cku3pOLhi^E+A>Yu&^R8gBpMlc zVi18k(7REH2OCv+wx5L*6hFK{$unDaT|q_`u)73TP`gPGN)9t3vjw4nv0i=YI2%g= zRk^{=e2*nk7bD*&-Y?Qg=|GnvMAMUT!cR6J@#qf3G_CX;3O@W+9Kevi#<&>wOb*r$ zy{k>0{D}ksW-8`gm6#vE_-f@NWkG)5PUIN83S==wZF#BnZWy3I1p4 z{}gy%Y&QgK-K$f`{;f9|b=tpt1Qh<)vHvp@USU?v3_pZ(c17I4{6(4ugYUtT#^+e` z!j=2++Wh(=@VfeZSAx%lR`whefiXj%_p9RsztF1Afk>q&7{ih@qt14bS=M-52r{!< ziB5o(Z*F+nwtmt*%~Rj9zbKg&u@Iv>;^Br|3?vXA2Qj!-1KbUf&87(EjtCdMZ%myh&H6=!H^sAyJ&^s zG!|PV258sIcaFog7C!gTEgxL!;|=Ct#$}ZmIYT3E$7rH9uJ+SePJwrnZ>ATKYYazt zC+17_hn`y?V}G{>_}Ahb+~$y3am%~9d)jk1sH;!qp|2hdtPRknP(&K3Sr=s45u@p{&N43FyRC&99%$ZXmsZNB@=3mN zZa12EPl{<;%e!{2bT?F<>Xs0ksVQP z>WtgH;HWFT{z)A}u;G;t9obD-M*}NLiqd@%bKWBNFP|elte>>uzZ%z-DM+c;PH8~6 zZOK+N5o2OW%7%FZjvJ98cjnj%^a?bZgM#}_Zz@m+n?tgYg1M4{i^gR^zW-Y?#vcKx z%Xdj9Q1?GdA_(%{B|uGl_58mn16E01{1H&PGn4hNJ>3?a0aIsQThbaO;G{;Z9|Li0 zkW_YiPEJg|gMzn1xhEB|4Gr(zGyAT-+nzO+#G*ah6qbkBdK~JMEuQqTt4jLu2 z_`vO)5a85jXThNNt&5>9`#wcR`R#N(m0crM|a-7`BbYA2$KC%i&N{83{nc@A^ zyArgYGBA7*->H7TOP6?1g?Qq-ZwgB6>OBH(%7FspO7!H=DOr+)_3f9~-t}WRR4C#< zC1jDwm3#NQH{i3Qv)fYNE_XgZ^DmTQM&7z!6pxLntye@S-FToV->#~g4vjN+#4zj5 zR?pgzN4ofEi6jn@1FR?kDYlwlH6;ADYOpHnkj3a98unjdM7Z5Vk&izp>YAe$!;7su zCKV3Rv9WbZEHK^P2+A_9mZHdwxL8j^^6J7I30%Kcl4@?Q{9aqLL&g*>+`SA)G1G*? z&R-9bQTs^k&Ovfx$3kz0-7{Hxnl$&>oIku4>ri6FqDAdX0DVEDTwNI4$J1Lfunyj1 z6^!WV2V4W?o=gMW+W;gA=}AeLNpSL?rTeqVCH~tcxBZGWB!4Wi|KC)cK50+}x+^5c z+1$}Jh?i^Np%(MEN$?(fdD(P$UM#0S4ov?cI7GwM(w6srwm!5Ul~yi7$;_Lg+mdeF zpP0A@`OW@igZ%c` zyo<7#S?n;}UC|EQp99299z0pNJUO)T2|XxBpW#X`_>N%K2*?T(EJpGPOL9Iw!sq8u z!%M&$HVF0uR<_VelDuwlGSB#l0ERI+4h>gSa zn_QS$ysT9_-l*%RKvRj51$qE~Rsv*N)hS-)w0~aa^{~GrM$#`>*Cbz*fnhJ%kdRFM zS;h4hAn@>zWfG3uIYQVLlad= z;d@M=Z!}a}bdQ^vTHKAO5asP)2;}PtVGc~Y?Kt0+qOBU6R1r&3RZ`3>2YLw6+-PIr z)tFg1rJsUxQ8&gPDTk4%=uvtDd=!lU1Bk&Fb9QevlazHjT4QN=q(c%&M5Mh5-CV?UacQM^P-kr}lJpS;L&Vg6O%NSQ z14yeoL$3JpUhHP~<*Y+(cv-rGUAcpkhAxhQ*MLqVl&?Qg;j);0;L zn(uMyN{W2m^RlA>{%t|hL$Mp`3~3b*wgrR~2r_0atbUC`b5*&qF&5Lbb>5jIOjVj~ ziy=HkR3UVI)97cqf?ep%HD-M5=6k^sL;mc~%8HI;Z&p!$@K1`eST_;?4bS7BBa`Ve z1`Y%}>1P2hat4t;ZRjDuJj6Ma8T$f!;ZId!BOw zJ|PBst+LK+^(QfyM`j6{!!92P4Jwddmxc9C0Y_)g`l7`a^{Y1C-i!EpSzvxeN6}0m zMx6RVcG&=F%x)KeGzN2W1)dz^jsHy2iBu+fIeUJ`Tb1oDF{(^M^oIC^h*_k5A32?2 z6X9hRh6DBcROFQt0&k~5EC+X`L5GX zvsKINYN)v99l_>+zvps+6;l|Shik@RV*j9|M(mQPrK8-uQdrI9gmJNfVgZh=zh{2I zs5`5H*hgh%mheiPlm&uQ9rdkfBQr##UUv?}=?ih9RGy-Ps`=lZLA8fMaJk0dr4-yW zXwQteE3@mzcJ_5a8!V%vpTXPHcf5^L=hqw@jI}sqUWsy^PSGWT*3+McCu5<^7W*rPo zPzrOJCck!yUM;RtilfBP9BzQuTfiP^hAkp0_PzWgK&0h+!Y~nQWf+>lpn~oz z{B0~$>ISF+re79MnS7qL$=RzJ#>pg&Hs3o|S+`GSo}yu>g$DVUsUoTx)Nv5lbWdli zA{E+!^TIy7u&p)YieJw=1-@Y`t{aV^5Xag7DtrfH@nwX{EWH4yQ*7NW`N8TZlfVO8 zHp1nO`98D?RA0+x?-igOrQsR1kzVSc6(?T>?u3F0gZsYpunQNNw}RmuIfehv0eLaGrJ zUkoVXNKiiN=!o}7bJ^mU+j{np{4S8&uQHmaf4v#;;H}3Jp~Cd(xEPWCTDGIYOk?6y z*c=pRte{kuEvbJ)slphnsq+lcl+p)C%!Z1_71;e_Rg%kjC&%3~_t1miy69z+cmX}@ zD1&S8k3~<@*^=wQ3j8XpG6ARn^errF`OYt#JxdXLz z&sMi?wjWt|y|KcWTr9X;r`YRH(gQ+lt8%)ii27_pn!Tkdk!7Tl7b&;Yj*SKL#@O@# zjDR5T;PT*k7a@2Snu7_R6p{mvoT{88EEObC`YBypGM;qt_iUps{MMFfmi_e(BB8?2 z#cKtF?|Kf@1SHg1*vxFxcS`Fc0$K*#hZ^;f7vYG60k9lu)x@NPz;3*RLwph zbvM>((;sH_o60g%DT(7We_;fAw9qq)X3s%|9 zTC^K4kyTsKui0|;VNIX9+Ml*0snEFWggAlQX*075Ck4NbTQCq=49@|* z(cC4VYVQ67QZeKEYQ0TB(|}`FqdxYcUkzOXs@G2?^>>yVO^2Z-hXzjri2B0*{&%@O z%9QEW@@<7*0~NzqPIzp#U-P)x3CMbhuj}k1AExHY3Kai`$BFSb>-Gak$APe$P!BS{ z*ymp>xt`AU_9cr-e6%FOi z?8vBqA&e{8PU&SD=;RDZD)or#y44thxEFVk<&`S#{1G7!Knd`ih>i}~O$hcQ721vs z0rkfzqb%-`J%Mq3Ds%C>OTcq*^IzXs$ltrl-TwGp7bIpF=E_Vy2;vS=?dE@NBP_OE zU@kp|)LYmZWKL~KA~OGx-a6p1IxQaWTEUi9U;pEg`h88oIl^Qjj1 z>O=VveIg5=vooqazoBmur-j8wzwl_x?w1tl@kRwUeMUa3I35heZ*7~?^-8UI+g|Tm zHY!$dBuo)6qC42YQWd~@N%AalQ5>2sIaApvXkjVIx5>LqDUCytY61$@ASyTN^NmSoA*#;+L8_(*0jzla+H3SwJD)@`NEybp~$tH zmb&Mp9W;Xi49Hz*Te=-w>R>35%D~UCsnDi0HMIsXBAbFLAS8QX zYtdowF@o97)#s;@*u?jgFDQ2yF3HI+=p!-TqF*W%&j-Xr?9@0H8Z7I-LgHFt;4pob zd@!h^I698Dty9TO?nwAcytMNYt5|+0W6hZ!o`p`)GKMQp8<^*=vC;_lQ)A22{w(gX z1wO2t|9SjEFigGqeZq^1e5=(KQFIpVU)#8DP6qB?x`O z?8m9M`!z6*n;VI2!j%QJSiV$sCfW@RwjvcN2a9nmd}bfUNT_y41iD-p{T$&)8qDDan&)ZuMKIo zmvmWXUX(k~E{*cD>3RO_Bu;1Bvx zDRVX}_EiY&E3MByP{kHrY_1MiqL4p6k%Es!NW{i(J{9uVd4{qEPtPP&_VV0zYyIE= zS7QxHG~U-teVGTo>P6pC6Prv5DgIubgXDW^C} z)zU+OX8V;C@f>%n2pUB*`DLtVA%KQS#PN}Yac5Jjm+Ld6RLTVWIHZTqq^jFTmE zQOv4&g&Vu-8fsY${SO01>K_nP07ni77w1aKZ&W^!@G0@9lJ&AwuWv?+7RVJO>zjQs z62LHaT7X4`y<=wQF`Tuxr>ked+=?NnT|OZ6FwSyipBb(KF0OOrGbFn$XJE;1fW_;^ z`B2$^;>@zFXJ|C`&ttF*n+ctYAaR~k#2moZ+3wt@IDQ;CD3^Ep%^x#-;_p3FIO;XX zdZ)*X4=(7>pOv)bRaq&&Xsm#JnAcm%BylMJXwceyA5f-8wT<_*1}{o}=(Xk2-xcX43$hixSRupcu{P zSzn>b>5GUux#n+?dQored7&zVEJ+v$?#~rjTN9=+g5v!k!KoCVtUa!D@k_4g_nZ__ zDlK2r>(JtGrFCqG$jL2-2#v~r2%!qQ)uQL zxUJPs&$A$}jSeI+g~BQx!nKcum>bc9ARY+|*&mYZow2{qFe%zA=dq8hyETMH+eh%f zC#FCW^g#OTq(D=?9@~HJ5566AYnra`4fSQGX=&M!b$EHiQH<{uK2~IN{44JWn!D%2 zk(?ox9>n;`$jnVmFWH*0>o9oSDwM4KbMd29~bEOlNrr zakvHk3(N-8tQEt%{ChWw?Y|)%??(Vh{R_6X$pAD=s(;p-d=SkMR&fi)i#c<5&(lq> zpo`puE-(kLuUo3wi`c&)V>NZ5X*W*H7wQ3teK~aOBJw=Z8DQ_`T$I`o$qf6br$m2W z_<}8Z6uY!Gnk06*&H|bnE2R|6Kh2vhr>D1D`-#<>{cdClhu50iH66#LaMhNrsxMJH z1i$5katab9U_%@xn6p)rR0CLn1&W!1XNmO>}gR6Oi9r%8+@k?xS6u zh;IZ8oT|3e!(P&APq~klOyA-_XVo2I;UdpwgiBK6{4JMYI*oN%Ht!%rh0w2F0OM6B zrNzUYPwEbvU-c_#PfiB_kZbb+QfI4=YF^#clDL5EQO(lnc*1tPrmzr)f{O{OLgztQ zStktYZvIqM^6R2|PP21WEmEha79k~{Jh6kGq5tC-I_CvqMItgikz|fd3f-$$8>~Y+ zN$Ivl``J4p#%u|tLKX&-_D*F8hJ_QI#r+0DjiFD$c3VAG+<8`IzI=PLt zF%`&_vSgt$eS(cc9V_I~c4Uev$mO(w&=0VqMpm8g)33K*zavl?XhcPjyY(wLDX2$@)GDJyojebqwKF5PTdbzdB9|2Mr@B=A%;pMmDa3c5vg zEfI^eGkybCZO~ez8mYak+tMi6N0nc=++nta)F`)FL^c^4UCSfot@b7yg)T8Hj>%qw z-O|(uP3Ea`qrj=EhHIqkwUu!`p=;@^0o-NX`9-_P7k)U~CU>|d}phv=196OtAv5nN=qP1`JndSaKAkRdz3 z*P_=yT9R~8>A*7jRH~|L`Rgw|#`PLTKM)i0ino>oVySAw3PdFedy4o%ef+aL9M8%( z>t!Pz0kuIM4RB0k0WRw+A}#0uMOo4qDiNxh(!et|e8JvVW@nP1PL*FEIoP~dUHL$O zdn<<-G^UwBB%VA}h>904`!2Oa$l zt{5+wkkR`qr6H-~Ad*PT9v78Kzss$pIJTszav(50cR#iM$Pt)hiO+V(5-Vb+VX28x zF^CT|HzMX5z(4>|lVUV{U=69Znv|u-t;h|c-Xf>WMn(1s2TefiF2^m*v3m(EUNGYX zjhN&rNvPsH4{s9sl@~{i0_5J)H7JbI=_rEB+S3fj_t`}zQXNu5$&P@*%7WIn`pye_ zh2@n-V-&T_2|y&WbT!FQbZUBVW*WT%30PY7$DK-;wgF^eJH^Z!pH~f|hLzDQ!4!9p zz&t_2PzrdT-J@RY-uxk{NPj`zoG+!UexjrVLXS3)SKJvGYX5YNPrL_uiOR74z;Tu^ zHdVHzXB>{4THHx>V^o3nI}2)9f2&XAEKksup}*KwPn|&RWx_-(2~>MV{A&&OTNYHi z)Ku)pQ2p|;Hx0w!mw1`(`10Ehl9YZ>XAjx%m-aVrCTm@bN>+8XGeu-jG`+BU5NU7T z8`lx2d#|A&O+-FBu3%e>si5DI%c_iFMS;>$Dz%JdE)3C_J8cM-MW&=uj#+gmt$1jM4V z-=7h&|H4Z<{kU`5;BxQl+Aswf*oTXM@nk3v0KJLrs5nL|Rbq4XG!G@dnKXNuo*6Lt zqZ(hk3s!_PR)ZCIV2N2_-Ccjo#5(ahedz@6pUHH@|6I8U|HNcAlc1z|{uVd2G0 zw{S{br_}^%u)YGtXB~$~^L9;-#i0Q8z$}$K?S&X*Kku81yE%`B8I)m7=GhS}FZh(5 zg$XVS&N1eDR+pn}7`@;auK;OkPQ{RQse#N@=e}biGr=cWHQVatLat7<(uUYg%;5HX zHoI6%kaT-Up!sA9PojnmhaK)zKw(Z;}G?nup9yYa!&7(JfM-pAY-|dc}&rI<$UJiAsAOrk8 zg16}huWh(nL*q}v-Aux}u!L=M`}^u+opAp`)KhdC1W5p3`$!er&}O#((3PC%wdbWZ zo!RjOvw!{9xQ`=IVuLD*sfS%_v>~7B&tQEq9ra8o`$Iaq%F4mchuy}{Sv)zvn~ z*hnscc@$}oFJ;n*%`G9Uog93^EfABvxx(ouJD7zRC>`Gr5^4EOo9a_h3L|V%_(;Yg z%1-mu>U8>0!Z|JAe1N7X!QHF2rD*Dno{pAc))Lo>`O56bt$=xT$Dar`*r z4t&Z;YE#EwzYCNfHdP0Teq|k?qYSW6_oQdbsxVfVvnl01(g21iW7h4Y7a!<;&bBQh z(~gOv*TNY!Z;mEQ&fvoXo4uFGgO+cs6a%NdPDx0U{oYWd<~q_Xli0c4?C9ia{^oLJ zlTN|KIg5VHH;PS2EoZT8L^?r98cQWo{@Q(e?vF?<&nf7 zww6Vtes8FPzD(nTDFh91_rZ$ET9YdX5~*KxF|JZ|yT@TI3G1^~gw?nsJprYC{AE5b z37H@AgF4yD*r?-+hl$_9SSo)Xf?#nRVQidsEi>LlfGhnYhu9!NDb2kCq3d>m%U^2V=BzFISh$_9~DwmIwhb*6w;QBOk& zEMW3FKz5+TY4w4v?Olhq=SQknclwSgo3)>)+&aD)$)}y@d|xc`4m$T}TxF3s!3)y? z2*;v60^q8@XGDgv^6PqU6nv%DJJo-?Qh@Fm`tEpta*D^!k!`ENslXuin_>UAJscA7 zefq0WAgx~?C zxJgtXl$EymqHtuXWq*7;LhU^o>w(*1Iuc&o(2ESzrL}Rr=HPXRFgwuBc_)NxqnqPh z$-BW2fts$&Cw6Y{Vne2_@Sm)hkiix?`-Y5nv-J1)rw_uq>;XcN;AaE;}YfRcU zlJzU!O*54dy0Rg!n`vxVm7Qly&|TQ7>8iP@FwH)xnL4^oNQ?Y_!-$TF7(?OLE*;6) z64wrkpjLSL#~fsn*ZxYlJ1f4%B&jTPxE$vkp^k61hH|(rVpF0gCywz5BQDtLCoWK$ zj(bPfR2w{*I~Ol+FE<7IiipeVzkbRp;&ph#u5-;+>o0Z7p9mk_Fnh}%OK2pv1x~IY zI3k&4R5E#8lTt)a+j*2IKzN#xaw)e|6G0{jFQU*B;}qV>cUWP1VLkU&r%+h=I#GM1 zV12!h%Uk*5*>Te_&9hO=qXm*xWX8FxiGji z3Q%L(L`3n~E&aPfpnxTN^BMFu^hqRU{t<~$5v8e5_UsP%B);-1lIOX5tKgVpX~-^r znxZqdyAU$e{Lkwxw4fviSJ<=_8y6o+o7OBdz0SP98|kvo57|P4!6tCL2+M6}A}faC zlw!{QCvr=#utBv!97#$^t<-^i9(%Va&FQbXbvh;yg+{sC_QeG^W`Ry(l!j)S>RoeI zU)db}`7*^8Wm73oogO}Y9&&U_(_$+X2s577pr^iJJZ;eq1WSWMXg7g$XvyUdPdAS` zSxUOo8|0Mfmi}E?LgnI>aYWuBW+tww%`I$b)s~L{XhGySOPh-?GQ5V&eu0M;fz5 zI`Z!HRD7O2f9isQV}(Vgs_%2}7wQLh@U>&D(L>$aIZamlvUk|Wdq@14uTHOY+|k99 zqApOi**u%=7@&7vlqe=xvx!0y3-PxDggUeHP4Bp49)1$y ziRJ8Vb;*k1t>qO#rgV)|oH+<9%bd?ZBqIR~&M$QBeIArrz|-YlTOI+q2lb+m!UHYc zk%PfF+dRXoEi+0IE<24*oE?mVqlBz@R3@j2`8syncfGFnLX0nT=kB7vbErmMm@=Wk zZPZe1k396uKfZu`11Q}Ec}+N91p1&&MR>uNzP6ZO+jo`qGA`X#yh}?vnVCN75zZXL zlcv+hHT8Ji&&eOGmhReMz1KJPqvZIjP;dH2Pq!vHd>N(GpClD_*(BfM*T~B6MXW|< z0?qFTBIAw`+nAKB>oF}1-+hU$vd2Co^^nkTlM7cc)DCedLf%pcXStN)1Yu~qo)XK zPQ#{%{NAoD7N-vmB>h&)^a#`&^xv{T!HVv8g5tcmV9uJ$;NbOM{N3?pn7mqtiy;@0im_UL{Jj-eTJHJ_7U~0aF%4H(gs2Q?~E4 zuV=u+6<0tJba#OE9NMcDmf%0hWw@zx$x_U5VJ4_SCiX6-2|fOgi$&E)Pr zHo`E36kSZhGF=8=Ui5^UwTI zedZS$a6W-$3&MN+>3%}ZnW*N!mOkY#tod1Q^M5`ajF8gl7FBtJgi4bA&@+jv3#d|; zzWD8S=aP*BP3@bM2KLRgJLwCpS*-92V!$!%mXWm)6@kP_H1Hg1&Kk01_2SQ~S`H&_ z4ul>MQHfJe?6*AeVYOsl$0ZZEMRRvZo{ysFIBH2rklw_+u<7&maIC&7R6o0s%y zLo$uhoEr{Y#Q;CQ9XN|Po4kDc&5G|dK($qCuPyWPLkP~(Uw*Sa0%9^R!>-1SzZ?1K zOMF(nlfK*IdRyu6f@<Nd`F6c{!Vs@wDW{_=TrBO{I2+y`@rfeu*D_fI}-;j z)yO+{Uab&=CYg6khP0PV>&gUca}JtMG#u#u_?O_v02OlSAK#lZSUW0~Dt?2>AwRx%{iUb6w%$!SbkF;@Q;PMCs54*9 z&oDnbIRU27d)Zf-bknr))|lQ>@@^n5=_`aS;p7Nhn)$Ls5qufP!|(Cux~F20YW`Q1*4Eokee`)#=|(+V$H z>AR 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 ( -
- + <> +
+ -
- -
- -
-
- - - - - +
+ +
+ +
- -
- -
+ + + + +
+ + {/* Artwork navigator — prev/next arrows, keyboard, swipe, no page reload */} + + + {/* Fullscreen viewer modal */} + + ) } @@ -62,6 +137,8 @@ if (el) { presentXl={parse('presentXl')} presentSq={parse('presentSq')} canonicalUrl={parse('canonical', '')} + isAuthenticated={parse('isAuthenticated', false)} + comments={parse('comments', [])} />, ) } diff --git a/resources/js/Search/SearchBar.jsx b/resources/js/Search/SearchBar.jsx new file mode 100644 index 00000000..fe31bf1c --- /dev/null +++ b/resources/js/Search/SearchBar.jsx @@ -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 ( +
+
+ 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" + /> + +
+ + {open && suggestions.length > 0 && ( +
    + {suggestions.map((item) => ( +
  • + +
  • + ))} +
  • + + See all results for "{query}" + + +
  • +
+ )} +
+ ) +} diff --git a/resources/js/components/Topbar.jsx b/resources/js/components/Topbar.jsx index b6e36ebe..92e257e6 100644 --- a/resources/js/components/Topbar.jsx +++ b/resources/js/components/Topbar.jsx @@ -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() {
-
- - -
+
@@ -29,3 +26,4 @@ export default function Topbar() { ) } + diff --git a/resources/js/components/artwork/ArtworkAwards.jsx b/resources/js/components/artwork/ArtworkAwards.jsx new file mode 100644 index 00000000..8ad0dc86 --- /dev/null +++ b/resources/js/components/artwork/ArtworkAwards.jsx @@ -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 ( +
+

Awards

+ + {error && ( +

{error}

+ )} + +
+ {MEDALS.map(({ key, label, emoji }) => { + const isActive = viewerAward === key + const isPending = loading === key + + return ( + + ) + })} +
+ + {awards.score > 0 && ( +

+ Score: {awards.score} +

+ )} + + {!isAuthenticated && ( +

+ Sign in to award this artwork +

+ )} +
+ ) +} diff --git a/resources/js/components/artwork/ArtworkBreadcrumbs.jsx b/resources/js/components/artwork/ArtworkBreadcrumbs.jsx new file mode 100644 index 00000000..74d4a0ad --- /dev/null +++ b/resources/js/components/artwork/ArtworkBreadcrumbs.jsx @@ -0,0 +1,88 @@ +import React from 'react' + +function Separator() { + return ( + + ) +} + +function Crumb({ href, children, current = false }) { + const base = 'text-xs leading-none truncate max-w-[180px] sm:max-w-[260px]' + if (current) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) +} + +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 ( + + ) +} diff --git a/resources/js/components/artwork/ArtworkComments.jsx b/resources/js/components/artwork/ArtworkComments.jsx new file mode 100644 index 00000000..4730ab43 --- /dev/null +++ b/resources/js/components/artwork/ArtworkComments.jsx @@ -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 ( + {user.name + ) + } + const initials = (user?.name || user?.username || '?').slice(0, 1).toUpperCase() + return ( + + {initials} + + ) +} + +export default function ArtworkComments({ comments = [] }) { + if (!comments || comments.length === 0) return null + + return ( +
+

+ Comments{' '} + ({comments.length}) +

+ +
    + {comments.map((comment) => ( +
  • + {comment.user?.profile_url ? ( + + + + ) : ( + + + + )} + +
    +
    + {comment.user?.profile_url ? ( + + {comment.user.name || comment.user.username || 'Member'} + + ) : ( + + {comment.user?.name || comment.user?.username || 'Member'} + + )} + +
    + +

    + {comment.content} +

    +
    +
  • + ))} +
+
+ ) +} diff --git a/resources/js/components/artwork/ArtworkHero.jsx b/resources/js/components/artwork/ArtworkHero.jsx index 58e6687c..02058575 100644 --- a/resources/js/components/artwork/ArtworkHero.jsx +++ b/resources/js/components/artwork/ArtworkHero.jsx @@ -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 (
- {blurBackdropSrc && ( -
- -
- )} + {hasRealArtworkImage && ( -
+
)} -
+
e.key === 'Enter' && onOpenViewer() : undefined} + > {artwork?.title + + {/* Prev arrow */} + {hasPrev && ( + + )} + + {/* Next arrow */} + {hasNext && ( + + )} + + {onOpenViewer && ( + + )}
{hasRealArtworkImage && ( diff --git a/resources/js/components/artwork/ArtworkMeta.jsx b/resources/js/components/artwork/ArtworkMeta.jsx index be806a96..058b4428 100644 --- a/resources/js/components/artwork/ArtworkMeta.jsx +++ b/resources/js/components/artwork/ArtworkMeta.jsx @@ -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 (

{artwork?.title}

-
+ +
Author
{author}
diff --git a/resources/js/components/artwork/ArtworkTags.jsx b/resources/js/components/artwork/ArtworkTags.jsx index f5daf81b..dff7e497 100644 --- a/resources/js/components/artwork/ArtworkTags.jsx +++ b/resources/js/components/artwork/ArtworkTags.jsx @@ -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] diff --git a/resources/js/components/viewer/ArtworkNavigator.jsx b/resources/js/components/viewer/ArtworkNavigator.jsx new file mode 100644 index 00000000..f7d0f911 --- /dev/null +++ b/resources/js/components/viewer/ArtworkNavigator.jsx @@ -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; +} diff --git a/resources/js/components/viewer/ArtworkViewer.jsx b/resources/js/components/viewer/ArtworkViewer.jsx new file mode 100644 index 00000000..730789c3 --- /dev/null +++ b/resources/js/components/viewer/ArtworkViewer.jsx @@ -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 ( +
+ {/* Close button */} + + + {/* Image — stopPropagation so clicking image doesn't close modal */} + {artwork?.title e.stopPropagation()} + draggable={false} + loading="eager" + decoding="async" + /> + + {/* Title / author footer */} + {artwork?.title && ( +
e.stopPropagation()} + > + {artwork.title} +
+ )} + + {/* ESC hint */} + + ESC to close + +
+ ); +} diff --git a/resources/js/components/viewer/viewer.test.jsx b/resources/js/components/viewer/viewer.test.jsx new file mode 100644 index 00000000..0a385ef2 --- /dev/null +++ b/resources/js/components/viewer/viewer.test.jsx @@ -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 ?
{n.prevId}-{n.nextId}
: null + } + + render() + 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 ?
{String(n.prevId)}|{String(n.nextId)}
: null + } + + render() + 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 ?
{n.prevId}-{n.nextId}
: null + } + + render() + 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 ?
{String(n.prevId)}|{String(n.nextId)}
: null + } + + render() + 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
+ } + + render() + + 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( + + ) + + 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
+ } + + render() + + // 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
+ } + + render() + + 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
+ } + + render() + + // 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( + {}} 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( + {}} + 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( + + ) + + // 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( + + ) + + 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( + + ) + + 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( + {}} + 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') + }) +}) diff --git a/resources/js/entry-search.jsx b/resources/js/entry-search.jsx new file mode 100644 index 00000000..7b0e2b23 --- /dev/null +++ b/resources/js/entry-search.jsx @@ -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() +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', mount) +} else { + mount() +} diff --git a/resources/js/lib/nav-context.js b/resources/js/lib/nav-context.js new file mode 100644 index 00000000..c0829fb4 --- /dev/null +++ b/resources/js/lib/nav-context.js @@ -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 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(); + } +})(); diff --git a/resources/js/lib/useNavContext.js b/resources/js/lib/useNavContext.js new file mode 100644 index 00000000..718ac911 --- /dev/null +++ b/resources/js/lib/useNavContext.js @@ -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 }; +} diff --git a/resources/js/nova.js b/resources/js/nova.js index cb98fd87..8f1ff5b9 100644 --- a/resources/js/nova.js +++ b/resources/js/nova.js @@ -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]'; diff --git a/resources/views/artworks/show.blade.php b/resources/views/artworks/show.blade.php index 426fabe8..402217b8 100644 --- a/resources/views/artworks/show.blade.php +++ b/resources/views/artworks/show.blade.php @@ -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())'>
@vite(['resources/js/Pages/ArtworkPage.jsx']) diff --git a/resources/views/components/artwork-card.blade.php b/resources/views/components/artwork-card.blade.php index bea9e462..fcdd2175 100644 --- a/resources/views/components/artwork-card.blade.php +++ b/resources/views/components/artwork-card.blade.php @@ -118,7 +118,11 @@ } @endphp -
-
+
@forelse ($artworks as $art) - @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']) @endpush @section('content') -
- -
-
-
-
Newest Artworks
-
-
- @foreach($artworks as $art) - - @endforeach -
- - -
-
-
+{{-- ═══════════════════════════════════════════════════════════ + PROFILE HERO + ═══════════════════════════════════════════════════════════ --}} +
+
+ + +{{-- ═══════════════════════════════════════════════════════════ + STATS STRIP + ═══════════════════════════════════════════════════════════ --}} +
+
+
+ @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) +
+
{{ $si['value'] }}
+
+ {{ $si['label'] }} +
+
+ @endforeach +
+
+
+ +{{-- ═══════════════════════════════════════════════════════════ + MAIN CONTENT + ═══════════════════════════════════════════════════════════ --}} +
+
+ + {{-- ─── LEFT COLUMN (artworks) ─── --}} +
+ + {{-- Featured Artworks --}} + @if(isset($featuredArtworks) && $featuredArtworks->isNotEmpty()) +
+
+ + Featured Artworks +
+
+
+ @php $feat = $featuredArtworks->first() @endphp + {{-- Main featured --}} + +
+ {{ e($feat->name) }} +
+

{{ e($feat->name) }}

+ @if($feat->label) +

{{ e($feat->label) }}

+ @endif + @if($feat->featured_at) +

+ + Featured {{ \Carbon\Carbon::parse($feat->featured_at)->format('d M, Y') }} +

+ @endif +
+ {{-- Side featured (2nd & 3rd) --}} + @if($featuredArtworks->count() > 1) +
+ @foreach($featuredArtworks->slice(1) as $sideArt) + +
+ {{ e($sideArt->name) }} +
+

{{ e($sideArt->name) }}

+
+ @endforeach +
+ @endif +
+
+
+ @endif + + {{-- Newest Artworks --}} +
+
+ + Newest Artworks + + View Gallery + +
+
+ @if(isset($artworks) && !$artworks->isEmpty()) + + + + @else +

+ + No artworks yet. +

+ @endif +
+
+ + {{-- Favourites --}} + @if(isset($favourites) && $favourites->isNotEmpty()) +
+
+ + Favourites +
+
+
+ @foreach($favourites as $fav) + + {{ e($fav->name) }} + + @endforeach +
+
+
+ @endif + +
{{-- end left --}} + + {{-- ─── RIGHT SIDEBAR ─── --}} +
+ + {{-- Profile Info --}} +
+
+ + Profile +
+
+ + + + + + @if($displayName && $displayName !== $uname) + + @endif + + + + + @if($birthdate) + + @endif + @if($countryName) + + + + + @endif + @if($website) + + + + + @endif + @if($lastVisit) + + + + + @endif + + + + +
Username{{ e($uname) }}
Real Name{{ e($displayName) }}
Gender + + {{ $gender['label'] }} +
Birthday{{ $birthdate }}
Country + @if($profile?->country_code) + {{ e($countryName) }} + @endif + {{ e($countryName) }} +
Website + + + {{ e(parse_url($website, PHP_URL_HOST) ?? $website) }} + +
Last Activity + {{ $lastVisit->format('d.M.Y') }} + + {{ $lastVisit->format('H:i') }} +
Member since{{ $user->created_at ? $user->created_at->format('M Y') : 'N/A' }}
+
+
+ + {{-- About Me --}} + @if($about) +
+
+ + About Me +
+
+
+ {!! nl2br(e($about)) !!} +
+ @if(strlen($about) > 300) + + @endif +
+
+ @endif + + {{-- Statistics --}} + @if($stats) +
+
+ + Statistics +
+
+ + @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]) + + + + + @endforeach +
{{ $label }} + {{ $value }} + @if($iconClass)@endif +
+
+
+ @endif + + {{-- Social Links --}} + @if(isset($socialLinks) && $socialLinks->isNotEmpty()) +
+
+ + Social Links +
+
+ @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 + + + {{ $si['label'] }} + + @endforeach +
+
+ @endif + + {{-- Recent Followers --}} + @if(isset($recentFollowers) && $recentFollowers->isNotEmpty()) +
+
+ + Followers + + {{ number_format($followerCount) }} + + + All + +
+
+
+ @foreach($recentFollowers as $follower) + + {{ e($follower->uname) }} + + @endforeach +
+
+
+ @elseif($followerCount > 0) +
+
+ + Followers + + {{ number_format($followerCount) }} + +
+
+ @endif + + {{-- Profile Comments --}} +
+
+ + Comments + @if(isset($profileComments) && $profileComments->isNotEmpty()) + + {{ $profileComments->count() }} + + @endif +
+
+ @if(!isset($profileComments) || $profileComments->isEmpty()) +

No comments yet.

+ @else +
+ @foreach($profileComments as $comment) +
+ + {{ e($comment->author_name) }} + +
+
+ + {{ e($comment->author_name) }} + + + {{ \Carbon\Carbon::parse($comment->created_at)->diffForHumans() }} + +
+

+ {!! nl2br(e($comment->body)) !!} +

+ @if(!empty($comment->author_signature)) +

+ {!! nl2br(e($comment->author_signature)) !!} +

+ @endif +
+
+ @endforeach +
+ @endif +
+
+ + {{-- Write Comment --}} + @auth + @if(auth()->id() !== $user->id) +
+
+ + Write a Comment +
+
+ @if(session('status') === 'Comment posted!') +
+ Comment posted! +
+ @endif +
+ @csrf + + @error('body') +

{{ $message }}

+ @enderror +
+ +
+
+
+
+ @endif + @else +
+
+

+ Log in + to leave a comment. +

+
+
+ @endauth + +
{{-- end right sidebar --}} + +
{{-- end flex --}} +
{{-- end container --}} + @endsection @push('scripts') diff --git a/resources/views/search/index.blade.php b/resources/views/search/index.blade.php new file mode 100644 index 00000000..888a0019 --- /dev/null +++ b/resources/views/search/index.blade.php @@ -0,0 +1,78 @@ +@extends('layouts.nova') + +@push('head') + + +@endpush + +@section('content') +
+ + {{-- Search header --}} +
+

Search

+ +
+ + @if(isset($q) && $q !== '') + {{-- Sort + filter bar --}} +
+ Sort by: + @foreach(['latest' => 'Newest', 'popular' => 'Most viewed', 'likes' => 'Most liked', 'downloads' => 'Most downloaded'] as $key => $label) + + {{ $label }} + + @endforeach + + @if($artworks->total() > 0) + + {{ number_format($artworks->total()) }} {{ Str::plural('result', $artworks->total()) }} + + @endif +
+ + {{-- Results grid --}} + @if($artworks->isEmpty()) +
+

No results for "{{ $q }}"

+

Try a different keyword or browse by category.

+
+ @else +
+ @foreach($artworks as $artwork) + + @endforeach +
+ +
+ {{ $artworks->appends(request()->query())->links('pagination::tailwind') }} +
+ @endif + @else + {{-- No query: show popular --}} +
+ Popular right now +
+
+ @foreach($popular as $artwork) + + @endforeach +
+ @endif + +
+@endsection diff --git a/resources/views/tags/show.blade.php b/resources/views/tags/show.blade.php index 03003701..e83b098b 100644 --- a/resources/views/tags/show.blade.php +++ b/resources/views/tags/show.blade.php @@ -1,37 +1,83 @@ @extends('layouts.nova') +@push('head') + + +@if(!empty($ogImage)) + + +@endif + + + + + +@if($artworks->previousPageUrl()) + +@endif +@if($artworks->nextPageUrl()) + +@endif +@endpush + @section('content') -
-
-
-

Tag: {{ $tag->name }}

-

Browse artworks tagged with “{{ $tag->name }}”.

+
+ + {{-- Header --}} +
+
+
+ + + +

{{ $tag->name }}

+

+ {{ number_format($artworks->total()) }} {{ Str::plural('artwork', $artworks->total()) }} +

-
-
- @if($artworks->isEmpty()) -
No artworks found for this tag.
- @else -
- @foreach($artworks as $artwork) - - @endforeach -
- -
- {{ $artworks->links('pagination::bootstrap-3') }} -
- @endif -
+ {{-- Sort controls --}} +
+ @foreach(['popular' => 'Most viewed', 'latest' => 'Newest', 'likes' => 'Most liked', 'downloads' => 'Most downloaded'] as $key => $label) + + {{ $label }} + + @endforeach
+ + {{-- Grid --}} + @if($artworks->isEmpty()) +
+ No artworks found for this tag yet. +
+ @else +
+ @foreach($artworks as $artwork) + + @endforeach +
+ +
+ {{ $artworks->appends(['sort' => $sort])->links('pagination::tailwind') }} +
+ @endif + +
@endsection diff --git a/resources/views/web/authors/top.blade.php b/resources/views/web/authors/top.blade.php new file mode 100644 index 00000000..7dc20f9e --- /dev/null +++ b/resources/views/web/authors/top.blade.php @@ -0,0 +1,112 @@ +@extends('layouts.nova') + +@section('content') + +{{-- ── Hero header ── --}} +
+
+
+

Community

+

Top Authors

+

Most popular members ranked by artwork {{ $metric === 'downloads' ? 'downloads' : 'views' }}.

+
+ + {{-- Metric switcher --}} + +
+
+ +{{-- ── Leaderboard ── --}} +
+ @php $offset = ($authors->currentPage() - 1) * $authors->perPage(); @endphp + + @if ($authors->isNotEmpty()) +
+ + {{-- Table header --}} +
+ # + Author + + {{ $metric === 'downloads' ? 'Downloads' : 'Views' }} + +
+ + {{-- Rows --}} +
+ @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 +
+ + {{-- Rank badge --}} +
+ @if ($rank === 1) + 1 + @elseif ($rank === 2) + 2 + @elseif ($rank === 3) + 3 + @else + {{ $rank }} + @endif +
+ + {{-- Author info --}} + + {{ $author->uname }} +
+
{{ $author->uname ?? 'Unknown' }}
+ @if (!empty($author->username)) +
{{ '@' . $author->username }}
+ @endif +
+
+ + {{-- Metric count --}} +
+ + {{ number_format($author->total ?? 0) }} + +
+
+ @endforeach +
+
+ +
+ {{ $authors->withQueryString()->links() }} +
+ @else +
+

No authors found.

+
+ @endif +
+ +@endsection diff --git a/resources/views/web/comments/latest.blade.php b/resources/views/web/comments/latest.blade.php new file mode 100644 index 00000000..a3b38960 --- /dev/null +++ b/resources/views/web/comments/latest.blade.php @@ -0,0 +1,82 @@ +@extends('layouts.nova') + +@section('content') + +{{-- ── Hero header ── --}} +
+
+

Community

+

Latest Comments

+

Most recent artwork comments from the community.

+
+
+ +{{-- ── Comment cards grid ── --}} +
+ @if ($comments->isNotEmpty()) +
+ @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 + + + @endforeach +
+ +
+ {{ $comments->withQueryString()->links() }} +
+ @else +
+

No comments found.

+
+ @endif +
+ +@endsection diff --git a/resources/views/web/comments/monthly.blade.php b/resources/views/web/comments/monthly.blade.php new file mode 100644 index 00000000..197da87c --- /dev/null +++ b/resources/views/web/comments/monthly.blade.php @@ -0,0 +1,90 @@ +@extends('layouts.nova') + +@section('content') + +{{-- ── Hero header ── --}} +
+
+
+

Community

+

Monthly Top Commentators

+

Members who posted the most comments in the last 30 days.

+
+ + + + + Last 30 days + +
+
+ +{{-- ── Leaderboard ── --}} +
+ @php $offset = ($rows->currentPage() - 1) * $rows->perPage(); @endphp + + @if ($rows->isNotEmpty()) +
+ + {{-- Table header --}} +
+ # + Member + Comments +
+ + {{-- Rows --}} +
+ @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 +
+ + {{-- Rank badge --}} +
+ @if ($rank === 1) + 1 + @elseif ($rank === 2) + 2 + @elseif ($rank === 3) + 3 + @else + {{ $rank }} + @endif +
+ + {{-- Member info --}} + + {{ $row->uname ?? 'User' }} +
+
{{ $row->uname ?? 'Unknown' }}
+
+
+ + {{-- Comment count --}} +
+ + {{ number_format((int)($row->num_comments ?? 0)) }} + +
+
+ @endforeach +
+
+ +
+ {{ $rows->withQueryString()->links() }} +
+ @else +
+

No comment activity in the last 30 days.

+
+ @endif +
+ +@endsection diff --git a/resources/views/web/daily-uploads.blade.php b/resources/views/web/daily-uploads.blade.php index 5eb922ee..f663fbf6 100644 --- a/resources/views/web/daily-uploads.blade.php +++ b/resources/views/web/daily-uploads.blade.php @@ -1,47 +1,111 @@ @extends('layouts.nova') @section('content') -
- -
-
- Choose date: -
    - @foreach($dates as $i => $d) -
  • {{ $d['label'] }}
  • - @endforeach -
- -
- @include('web.partials.daily-uploads-grid', ['arts' => $recent]) -
+{{-- ── Hero header ── --}} +
+
+
+

Skinbase

+

Daily Uploads

+

Browse all artworks uploaded on a specific date.

+ + + + + Latest Uploads +
+{{-- ── Date strip ── --}} +
+
+ @foreach($dates as $i => $d) + + @endforeach +
+
+ +{{-- ── Active date label ── --}} +
+

+ Showing uploads from {{ $dates[0]['label'] ?? 'today' }} +

+
+ +{{-- ── Grid container ── --}} +
+ @include('web.partials.daily-uploads-grid', ['arts' => $recent]) +
+ +{{-- ── Loading overlay (hidden) ── --}} + + @push('scripts') @endpush diff --git a/resources/views/web/downloads/today.blade.php b/resources/views/web/downloads/today.blade.php new file mode 100644 index 00000000..0b91c175 --- /dev/null +++ b/resources/views/web/downloads/today.blade.php @@ -0,0 +1,73 @@ +@extends('layouts.nova') + +@section('content') + +{{-- ── Hero header ── --}} +
+
+
+

Downloads

+

Most Downloaded Today

+

+ Artworks downloaded the most on . +

+
+
+ + + Live today + +
+
+
+ +{{-- ── Artwork grid ── --}} +
+ @if ($artworks && $artworks->isNotEmpty()) +
+ @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 --}} +
+ + @if ($downloads > 0) +
+ + + + + {{ number_format($downloads) }} + +
+ @endif +
+ @endforeach +
+ +
+ {{ $artworks->withQueryString()->links() }} +
+ @else +
+ + + +

No downloads recorded today yet.

+

Check back later as the day progresses.

+
+ @endif +
+ +@endsection diff --git a/resources/views/web/home.blade.php b/resources/views/web/home.blade.php index 2cdb2dba..e6cdcd6a 100644 --- a/resources/views/web/home.blade.php +++ b/resources/views/web/home.blade.php @@ -7,7 +7,7 @@ @endphp @section('content') -
+
@include('web.home.featured') @include('web.home.uploads') diff --git a/resources/views/web/home/featured.blade.php b/resources/views/web/home/featured.blade.php index dabb5620..3f62475a 100644 --- a/resources/views/web/home/featured.blade.php +++ b/resources/views/web/home/featured.blade.php @@ -1,37 +1,46 @@ {{-- Featured row — use Nova cards for consistent layout with browse/gallery --}} -
- @if(!empty($featured)) -
- @include('web.partials._artwork_card', ['art' => $featured]) -
- @else -
-
Featured Artwork
-
No featured artwork set.
-
- @endif +
+
+ + + +

Featured

+
- @if(!empty($memberFeatured)) -
- @include('web.partials._artwork_card', ['art' => $memberFeatured]) -
- @else -
-
Member Featured
-
No member featured artwork.
-
- @endif +
+ @if(!empty($featured)) +
+ @include('web.partials._artwork_card', ['art' => $featured]) +
+ @else +
+

No featured artwork set.

+
+ @endif -
-
- - Join SkinBase Community - -
Join Skinbase World
-

Join Skinbase and be part of our community. Upload, share and explore curated photography and skins.

- Create an account + @if(!empty($memberFeatured)) +
+ @include('web.partials._artwork_card', ['art' => $memberFeatured]) +
+ @else +
+

No member featured artwork.

+
+ @endif + +
+
+ + Join SkinBase Community + +
+
Join Skinbase World
+

Join our community — upload, share and explore curated photography and skins.

+ Create an account +
+
-
+
diff --git a/resources/views/web/members/photos.blade.php b/resources/views/web/members/photos.blade.php new file mode 100644 index 00000000..ad9dea44 --- /dev/null +++ b/resources/views/web/members/photos.blade.php @@ -0,0 +1,48 @@ +@extends('layouts.nova') + +@section('content') + +{{-- ── Hero header ── --}} +
+
+

Members

+

{{ $page_title ?? 'Member Photos' }}

+

Artwork submitted by the Skinbase community.

+
+
+ +{{-- ── Artwork grid ── --}} +
+ @php $items = is_object($artworks) && method_exists($artworks, 'toArray') ? $artworks : collect($artworks ?? []); @endphp + + @if (!empty($artworks) && (is_countable($artworks) ? count($artworks) > 0 : true)) +
+ @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 + + @endforeach +
+ + @if (is_object($artworks) && method_exists($artworks, 'links')) +
+ {{ $artworks->withQueryString()->links() }} +
+ @endif + @else +
+

No artworks found.

+
+ @endif +
+ +@endsection diff --git a/resources/views/web/partials/daily-uploads-grid.blade.php b/resources/views/web/partials/daily-uploads-grid.blade.php index 0fde101e..68e33ff8 100644 --- a/resources/views/web/partials/daily-uploads-grid.blade.php +++ b/resources/views/web/partials/daily-uploads-grid.blade.php @@ -1,14 +1,11 @@ @if($arts && count($arts)) -