fixes gallery

This commit is contained in:
2026-02-26 07:27:20 +01:00
parent 0032aec02f
commit d3fd32b004
14 changed files with 316 additions and 217 deletions

View File

@@ -21,6 +21,7 @@ class AvatarsMigrate extends Command
{--force : Overwrite existing migrated avatars} {--force : Overwrite existing migrated avatars}
{--remove-legacy : Remove legacy files after successful migration} {--remove-legacy : Remove legacy files after successful migration}
{--path=public/files/usericons : Legacy path to scan} {--path=public/files/usericons : Legacy path to scan}
{--user-id= : Only migrate a single user by ID}
'; ';
/** /**
@@ -54,8 +55,9 @@ class AvatarsMigrate extends Command
$force = $this->option('force'); $force = $this->option('force');
$removeLegacy = $this->option('remove-legacy'); $removeLegacy = $this->option('remove-legacy');
$legacyPath = base_path($this->option('path')); $legacyPath = base_path($this->option('path'));
$userId = $this->option('user-id') ? (int) $this->option('user-id') : null;
$this->info('Starting avatar migration' . ($dry ? ' (dry-run)' : '')); $this->info('Starting avatar migration' . ($dry ? ' (dry-run)' : '') . ($userId ? " for user={$userId}" : ''));
// Detect processing backend: Intervention preferred, GD fallback // Detect processing backend: Intervention preferred, GD fallback
$useIntervention = class_exists('Intervention\\Image\\ImageManagerStatic'); $useIntervention = class_exists('Intervention\\Image\\ImageManagerStatic');
@@ -65,7 +67,12 @@ class AvatarsMigrate extends Command
$bar = null; $bar = null;
User::with('profile')->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention) { $query = User::with('profile');
if ($userId) {
$query->where('id', $userId);
}
$query->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention) {
foreach ($users as $user) { foreach ($users as $user) {
/** @var UserProfile|null $profile */ /** @var UserProfile|null $profile */
$profile = $user->profile; $profile = $user->profile;

View File

@@ -2,7 +2,6 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\User;
use App\Support\UsernamePolicy; use App\Support\UsernamePolicy;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -12,39 +11,70 @@ use Illuminate\Support\Str;
class ImportLegacyUsers extends Command class ImportLegacyUsers extends Command
{ {
protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users}'; protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users} {--dry-run : Preview which users would be skipped/deleted without making changes}';
protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec'; protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec';
protected array $usedUsernames = [];
protected array $usedEmails = [];
protected string $migrationLogPath; protected string $migrationLogPath;
/** @var array<int,true> Legacy user IDs that qualify for import */
protected array $activeUserIds = [];
public function handle(): int public function handle(): int
{ {
$this->migrationLogPath = storage_path('logs/username_migration.log'); $this->migrationLogPath = storage_path('logs/username_migration.log');
@file_put_contents($this->migrationLogPath, '['.now()."] Starting legacy username policy migration\n", FILE_APPEND); @file_put_contents($this->migrationLogPath, '['.now()."] Starting legacy username policy migration\n", FILE_APPEND);
$this->usedUsernames = User::pluck('username', 'username')->filter()->all(); // Build the set of legacy user IDs that have any meaningful activity.
$this->usedEmails = User::pluck('email', 'email')->filter()->all(); // Users outside this set will be skipped (or deleted from the new DB if already imported).
$this->activeUserIds = $this->buildActiveUserIds();
$this->info('Active legacy users (uploads / comments / forum): ' . count($this->activeUserIds));
$chunk = (int) $this->option('chunk'); $chunk = (int) $this->option('chunk');
$dryRun = (bool) $this->option('dry-run');
$imported = 0; $imported = 0;
$skipped = 0; $skipped = 0;
$purged = 0;
if (! DB::getPdo()) { if (! DB::connection('legacy')->getPdo()) {
$this->error('Legacy DB connection "legacy" is not configured or reachable.'); $this->error('Legacy DB connection "legacy" is not configured or reachable.');
return self::FAILURE; return self::FAILURE;
} }
DB::table('users') DB::connection('legacy')->table('users')
->chunkById($chunk, function ($rows) use (&$imported, &$skipped) { ->chunkById($chunk, function ($rows) use (&$imported, &$skipped, &$purged, $dryRun) {
$ids = $rows->pluck('user_id')->all(); $ids = $rows->pluck('user_id')->all();
$stats = DB::table('users_statistics') $stats = DB::connection('legacy')->table('users_statistics')
->whereIn('user_id', $ids) ->whereIn('user_id', $ids)
->get() ->get()
->keyBy('user_id'); ->keyBy('user_id');
foreach ($rows as $row) { foreach ($rows as $row) {
$legacyId = (int) $row->user_id;
// ── Inactive user: no uploads, no comments, no forum activity ──
if (! isset($this->activeUserIds[$legacyId])) {
// If already imported into the new DB, purge it.
$existsInNew = DB::table('users')->where('id', $legacyId)->exists();
if ($existsInNew) {
if ($dryRun) {
$this->warn("[dry] Would DELETE inactive user_id={$legacyId} from new DB");
} else {
$this->purgeNewUser($legacyId);
$this->warn("[purge] Deleted inactive user_id={$legacyId} from new DB");
$purged++;
}
} else {
$this->line("[skip] user_id={$legacyId} no activity — skipping");
}
$skipped++;
continue;
}
if ($dryRun) {
$this->line("[dry] Would import user_id={$legacyId}");
$imported++;
continue;
}
try { try {
$this->importRow($row, $stats[$row->user_id] ?? null); $this->importRow($row, $stats[$row->user_id] ?? null);
$imported++; $imported++;
@@ -55,18 +85,59 @@ class ImportLegacyUsers extends Command
} }
}, 'user_id'); }, 'user_id');
$this->info("Imported: {$imported}, Skipped: {$skipped}"); $this->info("Imported: {$imported}, Skipped: {$skipped}, Purged: {$purged}");
return self::SUCCESS; return self::SUCCESS;
} }
/**
* Build a lookup array of legacy user IDs that qualify for import:
* uploaded at least one artwork (users_statistics.uploads > 0)
* posted at least one artwork comment (artworks_comments.user_id)
* created or posted to a forum thread (forum_topics / forum_posts)
*
* @return array<int,true>
*/
protected function buildActiveUserIds(): array
{
$rows = DB::connection('legacy')->select("
SELECT DISTINCT user_id FROM users_statistics WHERE uploads > 0
UNION
SELECT DISTINCT user_id FROM artworks_comments WHERE user_id > 0
UNION
SELECT DISTINCT user_id FROM forum_posts WHERE user_id > 0
UNION
SELECT DISTINCT user_id FROM forum_topics WHERE user_id > 0
");
$map = [];
foreach ($rows as $r) {
$map[(int) $r->user_id] = true;
}
return $map;
}
/**
* Remove all new-DB records for a given legacy user ID.
* Covers: users, user_profiles, user_statistics, username_redirects.
*/
protected function purgeNewUser(int $userId): void
{
DB::transaction(function () use ($userId) {
DB::table('username_redirects')->where('user_id', $userId)->delete();
DB::table('user_statistics')->where('user_id', $userId)->delete();
DB::table('user_profiles')->where('user_id', $userId)->delete();
DB::table('users')->where('id', $userId)->delete();
});
}
protected function importRow($row, $statRow = null): void protected function importRow($row, $statRow = null): void
{ {
$legacyId = (int) $row->user_id; $legacyId = (int) $row->user_id;
$rawLegacyUsername = (string) ($row->uname ?: ('user'.$legacyId));
$baseUsername = $this->sanitizeUsername($rawLegacyUsername);
$username = $this->uniqueUsername($baseUsername);
$normalizedLegacy = UsernamePolicy::normalize($rawLegacyUsername); // Use legacy username as-is (sanitized only, no numeric suffixing — was unique in old DB).
$username = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId)));
$normalizedLegacy = UsernamePolicy::normalize((string) ($row->uname ?? ''));
if ($normalizedLegacy !== $username) { if ($normalizedLegacy !== $username) {
@file_put_contents( @file_put_contents(
$this->migrationLogPath, $this->migrationLogPath,
@@ -75,7 +146,9 @@ class ImportLegacyUsers extends Command
); );
} }
$email = $this->prepareEmail($row->email ?? null, $username); // Use the real legacy email; only synthesise a placeholder when missing.
$rawEmail = $row->email ? strtolower(trim($row->email)) : null;
$email = $rawEmail ?: ($this->sanitizeEmailLocal($username) . '@users.skinbase.org');
$legacyPassword = $row->password2 ?: $row->password ?: null; $legacyPassword = $row->password2 ?: $row->password ?: null;
@@ -100,27 +173,40 @@ class ImportLegacyUsers extends Command
DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) { DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) {
$now = now(); $now = now();
$alreadyExists = DB::table('users')->where('id', $legacyId)->exists();
DB::table('users')->insert([ // All fields synced from legacy on every run
'id' => $legacyId, $sharedFields = [
'username' => $username, 'username' => $username,
'username_changed_at' => now(), 'username_changed_at' => $now,
'name' => $row->real_name ?: $username, 'name' => $row->real_name ?: $username,
'email' => $email, 'email' => $email,
'password' => $passwordHash,
'is_active' => (int) ($row->active ?? 1) === 1, 'is_active' => (int) ($row->active ?? 1) === 1,
'needs_password_reset' => true, 'needs_password_reset' => true,
'role' => 'user', 'role' => 'user',
'legacy_password_algo' => null, 'legacy_password_algo' => null,
'last_visit_at' => $row->LastVisit ?: null, 'last_visit_at' => $row->LastVisit ?: null,
'created_at' => $row->joinDate ?: $now,
'updated_at' => $now, 'updated_at' => $now,
]); ];
DB::table('user_profiles')->insert([ if ($alreadyExists) {
'user_id' => $legacyId, // Sync all fields from legacy — password is never overwritten on re-runs
// (unless --force-reset-all was passed, in which case the caller handles it
// separately outside this transaction).
DB::table('users')->where('id', $legacyId)->update($sharedFields);
} else {
DB::table('users')->insert(array_merge($sharedFields, [
'id' => $legacyId,
'password' => $passwordHash,
'created_at' => $row->joinDate ?: $now,
]));
}
DB::table('user_profiles')->updateOrInsert(
['user_id' => $legacyId],
[
'about' => $row->about_me ?: $row->description ?: null, 'about' => $row->about_me ?: $row->description ?: null,
'avatar' => $row->picture ?: null, 'avatar_legacy' => $row->picture ?: null,
'cover_image' => $row->cover_art ?: null, 'cover_image' => $row->cover_art ?: null,
'country' => $row->country ?: null, 'country' => $row->country ?: null,
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null, 'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
@@ -128,21 +214,22 @@ class ImportLegacyUsers extends Command
'birthdate' => $row->birth ?: null, 'birthdate' => $row->birth ?: null,
'gender' => $row->gender ?: 'X', 'gender' => $row->gender ?: 'X',
'website' => $row->web ?: null, 'website' => $row->web ?: null,
'created_at' => $now,
'updated_at' => $now, 'updated_at' => $now,
]); ]
);
// Do not duplicate `website` into `user_social_links` — keep canonical site in `user_profiles.website`. // Do not duplicate `website` into `user_social_links` — keep canonical site in `user_profiles.website`.
DB::table('user_statistics')->insert([ DB::table('user_statistics')->updateOrInsert(
'user_id' => $legacyId, ['user_id' => $legacyId],
[
'uploads' => $uploads, 'uploads' => $uploads,
'downloads' => $downloads, 'downloads' => $downloads,
'pageviews' => $pageviews, 'pageviews' => $pageviews,
'awards' => $awards, 'awards' => $awards,
'created_at' => $now,
'updated_at' => $now, 'updated_at' => $now,
]); ]
);
if (Schema::hasTable('username_redirects')) { if (Schema::hasTable('username_redirects')) {
$old = UsernamePolicy::normalize((string) ($row->uname ?? '')); $old = UsernamePolicy::normalize((string) ($row->uname ?? ''));
@@ -178,37 +265,6 @@ class ImportLegacyUsers extends Command
return UsernamePolicy::sanitizeLegacy($username); return UsernamePolicy::sanitizeLegacy($username);
} }
protected function uniqueUsername(string $base): string
{
$name = UsernamePolicy::uniqueCandidate($base);
$this->usedUsernames[$name] = $name;
return $name;
}
protected function prepareEmail(?string $legacyEmail, string $username): string
{
$legacyEmail = $legacyEmail ? strtolower(trim($legacyEmail)) : null;
$baseLocal = $this->sanitizeEmailLocal($username);
$domain = 'users.skinbase.org';
$email = $legacyEmail ?: ($baseLocal . '@' . $domain);
$email = $this->uniqueEmail($email, $baseLocal, $domain);
return $email;
}
protected function uniqueEmail(string $email, string $baseLocal, string $domain): string
{
$i = 1;
$local = explode('@', $email)[0];
$current = $email;
while (isset($this->usedEmails[$current]) || DB::table('users')->where('email', $current)->exists()) {
$current = $local . $i . '@' . $domain;
$i++;
}
$this->usedEmails[$current] = $current;
return $current;
}
protected function sanitizeEmailLocal(string $value): string protected function sanitizeEmailLocal(string $value): string
{ {
$local = strtolower(trim($value)); $local = strtolower(trim($value));

View File

@@ -36,6 +36,14 @@ class ArtworkNavigationController extends Controller
$prev = (clone $scope)->where('id', '<', $id)->orderByDesc('id')->first(); $prev = (clone $scope)->where('id', '<', $id)->orderByDesc('id')->first();
$next = (clone $scope)->where('id', '>', $id)->orderBy('id')->first(); $next = (clone $scope)->where('id', '>', $id)->orderBy('id')->first();
// Infinite loop: wrap around when reaching the first or last artwork
if (! $prev) {
$prev = (clone $scope)->where('id', '!=', $id)->orderByDesc('id')->first();
}
if (! $next) {
$next = (clone $scope)->where('id', '!=', $id)->orderBy('id')->first();
}
$prevSlug = $prev ? (Str::slug($prev->slug ?: $prev->title) ?: (string) $prev->id) : null; $prevSlug = $prev ? (Str::slug($prev->slug ?: $prev->title) ?: (string) $prev->id) : null;
$nextSlug = $next ? (Str::slug($next->slug ?: $next->title) ?: (string) $next->id) : null; $nextSlug = $next ? (Str::slug($next->slug ?: $next->title) ?: (string) $next->id) : null;
@@ -56,7 +64,7 @@ class ArtworkNavigationController extends Controller
*/ */
public function pageData(int $id): JsonResponse public function pageData(int $id): JsonResponse
{ {
$artwork = Artwork::with(['user.profile', 'categories.contentType', 'tags', 'stats']) $artwork = Artwork::with(['user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats'])
->published() ->published()
->find($id); ->find($id);

View File

@@ -18,7 +18,7 @@ final class ArtworkPageController extends Controller
{ {
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse
{ {
$artwork = Artwork::with(['user.profile', 'categories.contentType', 'tags', 'stats']) $artwork = Artwork::with(['user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats'])
->where('id', $id) ->where('id', $id)
->public() ->public()
->published() ->published()

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web; namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\ContentType;
use App\Models\Tag; use App\Models\Tag;
use App\Services\ArtworkSearchService; use App\Services\ArtworkSearchService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -40,18 +41,25 @@ final class TagController extends Controller
// Scout returns bare Eloquent models; without this, each card triggers N+1 queries. // Scout returns bare Eloquent models; without this, each card triggers N+1 queries.
$artworks->getCollection()->loadMissing(['user.profile']); $artworks->getCollection()->loadMissing(['user.profile']);
// OG image: first result's thumbnail // Sidebar: content type links (same as browse gallery)
$ogImage = null; $mainCategories = ContentType::orderBy('id')->get(['name', 'slug'])
if ($artworks->count() > 0) { ->map(fn ($type) => (object) [
$first = $artworks->getCollection()->first(); 'id' => $type->id,
$ogImage = $first?->thumbUrl('md'); 'name' => $type->name,
} 'slug' => $type->slug,
'url' => '/' . strtolower($type->slug),
]);
return view('tags.show', [ return view('gallery.index', [
'tag' => $tag, 'gallery_type' => 'tag',
'mainCategories' => $mainCategories,
'subcategories' => collect(),
'contentType' => null,
'category' => null,
'artworks' => $artworks, 'artworks' => $artworks,
'sort' => $sort, 'hero_title' => '#' . $tag->name,
'ogImage' => $ogImage, 'hero_description'=> 'All artworks tagged "' . e($tag->name) . '".',
'breadcrumbs' => collect(),
'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase', 'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase',
'page_meta_description' => 'Browse all Skinbase artworks tagged "' . $tag->name . '". Discover photography, wallpapers and skins.', 'page_meta_description' => 'Browse all Skinbase artworks tagged "' . $tag->name . '". Discover photography, wallpapers and skins.',
'page_canonical' => route('tags.show', $tag->slug), 'page_canonical' => route('tags.show', $tag->slug),

View File

@@ -106,6 +106,7 @@ class ArtworkResource extends JsonResource
'slug' => (string) $category->slug, 'slug' => (string) $category->slug,
'name' => (string) $category->name, 'name' => (string) $category->name,
'content_type_slug' => (string) ($category->contentType?->slug ?? ''), 'content_type_slug' => (string) ($category->contentType?->slug ?? ''),
'url' => $category->contentType ? $category->url : null,
])->values(), ])->values(),
'tags' => $this->tags->map(fn ($tag) => [ 'tags' => $this->tags->map(fn ($tag) => [
'id' => (int) $tag->id, 'id' => (int) $tag->id,

View File

@@ -49,12 +49,15 @@ function renderMarkdownSafe(text) {
export default function ArtworkDescription({ artwork }) { export default function ArtworkDescription({ artwork }) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const content = (artwork?.description || '').trim() const content = (artwork?.description || '').trim()
if (content.length === 0) return null
const collapsed = content.length > COLLAPSE_AT && !expanded const collapsed = content.length > COLLAPSE_AT && !expanded
const visibleText = collapsed ? `${content.slice(0, COLLAPSE_AT)}` : content const visibleText = collapsed ? `${content.slice(0, COLLAPSE_AT)}` : content
const rendered = useMemo(() => renderMarkdownSafe(visibleText), [visibleText]) // useMemo must always be called (Rules of Hooks) — guard inside the callback
const rendered = useMemo(
() => (content.length > 0 ? renderMarkdownSafe(visibleText) : null),
[content, visibleText],
)
if (content.length === 0) return null
return ( return (
<section className="rounded-xl bg-panel p-5 shadow-lg shadow-deep/30"> <section className="rounded-xl bg-panel p-5 shadow-lg shadow-deep/30">

View File

@@ -24,7 +24,27 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
<figure className="w-full"> <figure className="w-full">
<div className="relative mx-auto w-full max-w-[1280px]"> <div className="relative mx-auto w-full max-w-[1280px]">
{/* Outer flex row: left arrow | image | right arrow */}
<div className="flex items-center gap-2">
{/* Prev arrow — outside the picture */}
<div className="flex w-12 shrink-0 justify-center">
{hasPrev && (
<button
type="button"
aria-label="Previous artwork"
onClick={() => onPrev?.()}
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white/70 ring-1 ring-white/15 shadow-lg hover:bg-white/20 hover:text-white focus:bg-white/20 focus:text-white transition-colors duration-150"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
</div>
{/* Image area */}
<div className="relative min-w-0 flex-1">
{hasRealArtworkImage && ( {hasRealArtworkImage && (
<div className="absolute inset-0 -z-10" /> <div className="absolute inset-0 -z-10" />
)} )}
@@ -59,34 +79,6 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
}} }}
/> />
{/* Prev arrow */}
{hasPrev && (
<button
type="button"
aria-label="Previous artwork"
onClick={(e) => { e.stopPropagation(); onPrev?.(); }}
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/50 text-white/70 backdrop-blur-sm ring-1 ring-white/15 shadow-lg opacity-50 hover:opacity-100 focus:opacity-100 transition-opacity duration-150"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
{/* Next arrow */}
{hasNext && (
<button
type="button"
aria-label="Next artwork"
onClick={(e) => { e.stopPropagation(); onNext?.(); }}
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/50 text-white/70 backdrop-blur-sm ring-1 ring-white/15 shadow-lg opacity-50 hover:opacity-100 focus:opacity-100 transition-opacity duration-150"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
)}
{onOpenViewer && ( {onOpenViewer && (
<button <button
type="button" type="button"
@@ -105,6 +97,25 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
<div className="pointer-events-none absolute inset-x-8 -bottom-5 h-10 rounded-full bg-accent/25 blur-2xl" /> <div className="pointer-events-none absolute inset-x-8 -bottom-5 h-10 rounded-full bg-accent/25 blur-2xl" />
)} )}
</div> </div>
{/* Next arrow — outside the picture */}
<div className="flex w-12 shrink-0 justify-center">
{hasNext && (
<button
type="button"
aria-label="Next artwork"
onClick={() => onNext?.()}
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white/70 ring-1 ring-white/15 shadow-lg hover:bg-white/20 hover:text-white focus:bg-white/20 focus:text-white transition-colors duration-150"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
)}
</div>
</div>
</div>
</figure> </figure>
) )
} }

View File

@@ -4,14 +4,10 @@ export default function ArtworkTags({ artwork }) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const tags = useMemo(() => { const tags = useMemo(() => {
const primaryCategorySlug = artwork?.categories?.[0]?.slug || 'all'
const categories = (artwork?.categories || []).map((category) => ({ const categories = (artwork?.categories || []).map((category) => ({
key: `cat-${category.id || category.slug}`, key: `cat-${category.id || category.slug}`,
label: category.name, label: category.name,
href: category.content_type_slug && category.slug href: category.url || `/${category.content_type_slug}/${category.slug}`,
? `/browse/${category.content_type_slug}/${category.slug}`
: `/browse/${category.slug || ''}`,
})) }))
const artworkTags = (artwork?.tags || []).map((tag) => ({ const artworkTags = (artwork?.tags || []).map((tag) => ({

View File

@@ -91,9 +91,12 @@
return (int) $fallback; return (int) $fallback;
}; };
$imgWidth = max(1, $resolveDimension($art->width ?? null, 'width', 800)); // Use stored dimensions when available; otherwise leave ratio unconstrained
$imgHeight = max(1, $resolveDimension($art->height ?? null, 'height', 600)); // so the thumbnail displays at its natural proportions (no 1:1 or 16:9 forcing).
$imgAspectRatio = $imgWidth . ' / ' . $imgHeight; $hasDimensions = ($art->width ?? 0) > 0 && ($art->height ?? 0) > 0;
$imgWidth = $hasDimensions ? max(1, $resolveDimension($art->width, 'width', 0)) : null;
$imgHeight = $hasDimensions ? max(1, $resolveDimension($art->height, 'height', 0)) : null;
$imgAspectRatio = $hasDimensions ? ($imgWidth . ' / ' . $imgHeight) : null;
$contentUrl = $imgSrc; $contentUrl = $imgSrc;
$cardUrl = (string) ($art->url ?? ''); $cardUrl = (string) ($art->url ?? '');
@@ -134,8 +137,8 @@
<div class="absolute left-3 top-3 z-30 rounded-md bg-black/55 px-2 py-1 text-xs text-white backdrop-blur-sm">{{ $category }}</div> <div class="absolute left-3 top-3 z-30 rounded-md bg-black/55 px-2 py-1 text-xs text-white backdrop-blur-sm">{{ $category }}</div>
@endif @endif
<div class="nova-card-media relative overflow-hidden bg-neutral-900" style="aspect-ratio: {{ $imgAspectRatio }};"> <div class="nova-card-media relative overflow-hidden bg-neutral-900"@if($imgAspectRatio) style="aspect-ratio: {{ $imgAspectRatio }};"@endif>
<div class="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent"></div> <div class="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none"></div>
<picture> <picture>
<source srcset="{{ $imgAvifSrcset }}" type="image/avif"> <source srcset="{{ $imgAvifSrcset }}" type="image/avif">
<source srcset="{{ $imgWebpSrcset }}" type="image/webp"> <source srcset="{{ $imgWebpSrcset }}" type="image/webp">
@@ -148,9 +151,9 @@
@if($fetchpriority) fetchpriority="{{ $fetchpriority }}" @endif @if($fetchpriority) fetchpriority="{{ $fetchpriority }}" @endif
@if($loading !== 'eager') data-blur-preview @endif @if($loading !== 'eager') data-blur-preview @endif
alt="{{ e($title) }}" alt="{{ e($title) }}"
width="{{ $imgWidth }}" @if($imgWidth) width="{{ $imgWidth }}" @endif
height="{{ $imgHeight }}" @if($imgHeight) height="{{ $imgHeight }}" @endif
class="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]" class="{{ $imgAspectRatio ? 'h-full w-full object-cover' : 'w-full h-auto' }} transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]"
itemprop="thumbnailUrl" itemprop="thumbnailUrl"
/> />
</picture> </picture>

View File

@@ -49,7 +49,7 @@
<div class="relative"> <div class="relative">
<button class="hover:text-white inline-flex items-center gap-1" data-dd="cats"> <button class="hover:text-white inline-flex items-center gap-1" data-dd="cats">
Categories Explore
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" <svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2"> stroke-width="2">
<path d="M6 9l6 6 6-6" /> <path d="M6 9l6 6 6-6" />
@@ -57,12 +57,12 @@
</button> </button>
<div id="dd-cats" <div id="dd-cats"
class="hidden absolute left-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-hidden"> class="hidden absolute left-0 mt-2 w-64 rounded-lg bg-panel border border-panel shadow-sb overflow-hidden">
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/browse">All Artworks</a> <a class="block px-4 py-2 text-sm hover:bg-white/5" href="/browse"><i class="fa-solid fa-border-all mr-3 text-sb-muted"></i>All Artworks</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/photography">Photography</a> <a class="block px-4 py-2 text-sm hover:bg-white/5" href="/photography"><i class="fa-solid fa-camera mr-3 text-sb-muted"></i>Photography</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/wallpapers">Wallpapers</a> <a class="block px-4 py-2 text-sm hover:bg-white/5" href="/wallpapers"><i class="fa-solid fa-desktop mr-3 text-sb-muted"></i>Wallpapers</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/skins">Skins</a> <a class="block px-4 py-2 text-sm hover:bg-white/5" href="/skins"><i class="fa-solid fa-layer-group mr-3 text-sb-muted"></i>Skins</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/other">Other</a> <a class="block px-4 py-2 text-sm hover:bg-white/5" href="/other"><i class="fa-solid fa-folder-open mr-3 text-sb-muted"></i>Other</a>
<a class="block px-4 py-2 text-sm hover:bg-white/5" href="/featured-artworks">Featured Artwork</a> <a class="block px-4 py-2 text-sm hover:bg-white/5" href="/featured-artworks"><i class="fa-solid fa-star mr-3 text-sb-muted"></i>Featured Artwork</a>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,6 @@
[#6223] Red Cloud XP
→ windows-logo, retro-computing, red-dominant-colour, dark-mood, pixelated-graphics, digital-art, grunge-texture, office-shortcuts-menu, 90s-aesthetic, chromatic-aberration, high-contrast, textured-background
[#6225] Helping Hand zoomers (part 1)
→ desktop screenshot, computer icons, windows interface, digital-art, blue-grey tones, flat design, minimalist-style, iconography, organized layout, technical illustration, screen capture, system icons
[#6226] Helping Hand zoomers (part 2)
PS D:\Sites\Skinbase26>

View File

@@ -20,7 +20,7 @@
{{-- Latest uploads grid use same Nova gallery layout as /browse --}} {{-- Latest uploads grid use same Nova gallery layout as /browse --}}
<section class="px-6 pb-10 pt-6 md:px-10" data-nova-gallery data-gallery-type="home-uploads"> <section class="px-6 pb-10 pt-6 md:px-10" data-nova-gallery data-gallery-type="home-uploads">
<div class="{{ ($gridV2 ?? false) ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6' }}" data-gallery-grid> <div class="{{ ($gridV2 ?? false) ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6' }}" data-gallery-grid>
@forelse($latestUploads as $upload) @forelse($latestUploads as $upload)
<x-artwork-card :art="$upload" /> <x-artwork-card :art="$upload" />
@empty @empty
@@ -53,10 +53,10 @@
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(2, minmax(0, 1fr)); } [data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(4, minmax(0, 1fr)); } [data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
} }
@media (min-width: 2600px) { @media (min-width: 2600px) {
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); } [data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
} }
[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; } [data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
[data-nova-gallery].is-enhanced [data-gallery-pagination] { display: none; } [data-nova-gallery].is-enhanced [data-gallery-pagination] { display: none; }