fixes gallery
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
$imported = 0;
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
$skipped = 0;
|
$imported = 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,49 +173,63 @@ 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
|
||||||
'about' => $row->about_me ?: $row->description ?: null,
|
// (unless --force-reset-all was passed, in which case the caller handles it
|
||||||
'avatar' => $row->picture ?: null,
|
// separately outside this transaction).
|
||||||
'cover_image' => $row->cover_art ?: null,
|
DB::table('users')->where('id', $legacyId)->update($sharedFields);
|
||||||
'country' => $row->country ?: null,
|
} else {
|
||||||
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
|
DB::table('users')->insert(array_merge($sharedFields, [
|
||||||
'language' => $row->lang ?: null,
|
'id' => $legacyId,
|
||||||
'birthdate' => $row->birth ?: null,
|
'password' => $passwordHash,
|
||||||
'gender' => $row->gender ?: 'X',
|
'created_at' => $row->joinDate ?: $now,
|
||||||
'website' => $row->web ?: null,
|
]));
|
||||||
'created_at' => $now,
|
}
|
||||||
'updated_at' => $now,
|
|
||||||
]);
|
DB::table('user_profiles')->updateOrInsert(
|
||||||
|
['user_id' => $legacyId],
|
||||||
|
[
|
||||||
|
'about' => $row->about_me ?: $row->description ?: null,
|
||||||
|
'avatar_legacy' => $row->picture ?: null,
|
||||||
|
'cover_image' => $row->cover_art ?: null,
|
||||||
|
'country' => $row->country ?: null,
|
||||||
|
'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null,
|
||||||
|
'language' => $row->lang ?: null,
|
||||||
|
'birthdate' => $row->birth ?: null,
|
||||||
|
'gender' => $row->gender ?: 'X',
|
||||||
|
'website' => $row->web ?: null,
|
||||||
|
'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,
|
[
|
||||||
'downloads' => $downloads,
|
'uploads' => $uploads,
|
||||||
'pageviews' => $pageviews,
|
'downloads' => $downloads,
|
||||||
'awards' => $awards,
|
'pageviews' => $pageviews,
|
||||||
'created_at' => $now,
|
'awards' => $awards,
|
||||||
'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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,22 +41,29 @@ 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',
|
||||||
'artworks' => $artworks,
|
'mainCategories' => $mainCategories,
|
||||||
'sort' => $sort,
|
'subcategories' => collect(),
|
||||||
'ogImage' => $ogImage,
|
'contentType' => null,
|
||||||
'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase',
|
'category' => null,
|
||||||
|
'artworks' => $artworks,
|
||||||
|
'hero_title' => '#' . $tag->name,
|
||||||
|
'hero_description'=> 'All artworks tagged "' . e($tag->name) . '".',
|
||||||
|
'breadcrumbs' => collect(),
|
||||||
|
'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),
|
||||||
'page_robots' => 'index,follow',
|
'page_robots' => 'index,follow',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -162,16 +162,16 @@ function sidebarHeight() {
|
|||||||
/* Sidebar Statistics */
|
/* Sidebar Statistics */
|
||||||
if ($.fn.sparkline) {
|
if ($.fn.sparkline) {
|
||||||
|
|
||||||
sparkline1_color = '#159077';
|
sparkline1_color = '#159077';
|
||||||
sparkline2_color = '#00699e';
|
sparkline2_color = '#00699e';
|
||||||
sparkline3_color = '#9e494e';
|
sparkline3_color = '#9e494e';
|
||||||
|
|
||||||
if($.cookie('style-color') == 'dark') { sparkline1_color = '#159077'; sparkline2_color = '#00699e'; sparkline3_color = '#9e494e';}
|
if($.cookie('style-color') == 'dark') { sparkline1_color = '#159077'; sparkline2_color = '#00699e'; sparkline3_color = '#9e494e';}
|
||||||
if($.cookie('style-color') == 'red') { sparkline1_color = '#121212'; sparkline2_color = '#4AB2F8'; sparkline3_color = '#E0A832';}
|
if($.cookie('style-color') == 'red') { sparkline1_color = '#121212'; sparkline2_color = '#4AB2F8'; sparkline3_color = '#E0A832';}
|
||||||
if($.cookie('style-color') == 'blue') { sparkline1_color = '#E0A832'; sparkline2_color = '#D9534F'; sparkline3_color = '#121212';}
|
if($.cookie('style-color') == 'blue') { sparkline1_color = '#E0A832'; sparkline2_color = '#D9534F'; sparkline3_color = '#121212';}
|
||||||
if($.cookie('style-color') == 'green') { sparkline1_color = '#E0A832'; sparkline2_color = '#D9534F'; sparkline3_color = '#121212';}
|
if($.cookie('style-color') == 'green') { sparkline1_color = '#E0A832'; sparkline2_color = '#D9534F'; sparkline3_color = '#121212';}
|
||||||
if($.cookie('style-color') == 'dark') { sparkline1_color = '#159077'; sparkline2_color = '#00699e'; sparkline3_color = '#9e494e';}
|
if($.cookie('style-color') == 'dark') { sparkline1_color = '#159077'; sparkline2_color = '#00699e'; sparkline3_color = '#9e494e';}
|
||||||
|
|
||||||
/* Sparklines can also take their values from the first argument passed to the sparkline() function */
|
/* Sparklines can also take their values from the first argument passed to the sparkline() function */
|
||||||
var myvalues1 = [13, 14, 16, 15, 11, 14, 20, 14, 12, 16, 11, 17, 19, 16];
|
var myvalues1 = [13, 14, 16, 15, 11, 14, 20, 14, 12, 16, 11, 17, 19, 16];
|
||||||
var myvalues2 = [14, 17, 16, 12, 18, 16, 22, 15, 14, 17, 11, 18, 11, 12];
|
var myvalues2 = [14, 17, 16, 12, 18, 16, 22, 15, 14, 17, 11, 18, 11, 12];
|
||||||
@@ -261,7 +261,7 @@ function chatSidebar() {
|
|||||||
var setColor = function (color) {
|
var setColor = function (color) {
|
||||||
var color_ = 'color-'+color;
|
var color_ = 'color-'+color;
|
||||||
$('#theme-color').attr("href", "assets/css/colors/" + color_ + ".css");
|
$('#theme-color').attr("href", "assets/css/colors/" + color_ + ".css");
|
||||||
if ($.cookie) {
|
if ($.cookie) {
|
||||||
$.cookie('style-color', color);
|
$.cookie('style-color', color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,11 +274,11 @@ $('.theme-color').click(function (e) {
|
|||||||
$('.theme-color').parent().removeClass("c-white w-600");
|
$('.theme-color').parent().removeClass("c-white w-600");
|
||||||
$(this).parent().addClass("c-white w-600");
|
$(this).parent().addClass("c-white w-600");
|
||||||
|
|
||||||
if($.cookie('style-color') == 'dark') { sparkline1_color = '#159077'; sparkline2_color = '#00699e'; sparkline3_color = '#9e494e';}
|
if($.cookie('style-color') == 'dark') { sparkline1_color = '#159077'; sparkline2_color = '#00699e'; sparkline3_color = '#9e494e';}
|
||||||
if($.cookie('style-color') == 'red') { sparkline1_color = '#E0A832'; sparkline2_color = '#4AB2F8'; sparkline3_color = '#121212';}
|
if($.cookie('style-color') == 'red') { sparkline1_color = '#E0A832'; sparkline2_color = '#4AB2F8'; sparkline3_color = '#121212';}
|
||||||
if($.cookie('style-color') == 'blue') { sparkline1_color = '#E0A832'; sparkline2_color = '#D9534F'; sparkline3_color = '#121212';}
|
if($.cookie('style-color') == 'blue') { sparkline1_color = '#E0A832'; sparkline2_color = '#D9534F'; sparkline3_color = '#121212';}
|
||||||
if($.cookie('style-color') == 'green') { sparkline1_color = '#E0A832'; sparkline2_color = '#D9534F'; sparkline3_color = '#121212';}
|
if($.cookie('style-color') == 'green') { sparkline1_color = '#E0A832'; sparkline2_color = '#D9534F'; sparkline3_color = '#121212';}
|
||||||
if($.cookie('style-color') == 'cafe') { sparkline1_color = '#159077'; sparkline2_color = '#00699e'; sparkline3_color = '#9e494e';}
|
if($.cookie('style-color') == 'cafe') { sparkline1_color = '#159077'; sparkline2_color = '#00699e'; sparkline3_color = '#9e494e';}
|
||||||
|
|
||||||
/* We update Sparkline colors */
|
/* We update Sparkline colors */
|
||||||
$('.dynamicbar1').sparkline(myvalues1, {type: 'bar', barColor: sparkline1_color, barWidth: 4, barSpacing: 1, height: '28px'});
|
$('.dynamicbar1').sparkline(myvalues1, {type: 'bar', barColor: sparkline1_color, barWidth: 4, barSpacing: 1, height: '28px'});
|
||||||
@@ -448,12 +448,12 @@ if ($('.icon-validation').length && $.fn.parsley) {
|
|||||||
$(this).parsley().subscribe('parsley:field:success', function (formInstance) {
|
$(this).parsley().subscribe('parsley:field:success', function (formInstance) {
|
||||||
|
|
||||||
formInstance.$element.prev().removeClass('fa-exclamation c-red').addClass('fa-check c-green');
|
formInstance.$element.prev().removeClass('fa-exclamation c-red').addClass('fa-check c-green');
|
||||||
|
|
||||||
});
|
});
|
||||||
$(this).parsley().subscribe('parsley:field:error', function (formInstance) {
|
$(this).parsley().subscribe('parsley:field:error', function (formInstance) {
|
||||||
|
|
||||||
formInstance.$element.prev().removeClass('fa-check c-green').addClass('fa-exclamation c-red');
|
formInstance.$element.prev().removeClass('fa-check c-green').addClass('fa-exclamation c-red');
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -690,7 +690,7 @@ if ($('.gallery').length && $.fn.mixItUp) {
|
|||||||
|
|
||||||
$(this).mixItUp({
|
$(this).mixItUp({
|
||||||
animation: {
|
animation: {
|
||||||
enable: false
|
enable: false
|
||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
onMixLoad: function(){
|
onMixLoad: function(){
|
||||||
@@ -698,7 +698,7 @@ if ($('.gallery').length && $.fn.mixItUp) {
|
|||||||
$(this).mixItUp('setOptions', {
|
$(this).mixItUp('setOptions', {
|
||||||
animation: {
|
animation: {
|
||||||
enable: true,
|
enable: true,
|
||||||
effects: "fade",
|
effects: "fade",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
$(window).bind("load", function() {
|
$(window).bind("load", function() {
|
||||||
@@ -751,4 +751,4 @@ $(window).bind('resize', function (e) {
|
|||||||
tableResponsive();
|
tableResponsive();
|
||||||
}, 250);
|
}, 250);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -24,86 +24,97 @@ 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">
|
||||||
|
|
||||||
{hasRealArtworkImage && (
|
{/* Prev arrow — outside the picture */}
|
||||||
<div className="absolute inset-0 -z-10" />
|
<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>
|
||||||
|
|
||||||
<div
|
{/* Image area */}
|
||||||
className={`relative w-full aspect-video overflow-hidden ${onOpenViewer ? 'cursor-zoom-in' : ''}`}
|
<div className="relative min-w-0 flex-1">
|
||||||
onClick={onOpenViewer}
|
{hasRealArtworkImage && (
|
||||||
role={onOpenViewer ? 'button' : undefined}
|
<div className="absolute inset-0 -z-10" />
|
||||||
aria-label={onOpenViewer ? 'View fullscreen' : undefined}
|
)}
|
||||||
tabIndex={onOpenViewer ? 0 : undefined}
|
|
||||||
onKeyDown={onOpenViewer ? (e) => e.key === 'Enter' && onOpenViewer() : undefined}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={md}
|
|
||||||
alt={artwork?.title ?? 'Artwork'}
|
|
||||||
className="absolute inset-0 h-full w-full object-contain"
|
|
||||||
loading="eager"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<img
|
<div
|
||||||
src={lg}
|
className={`relative w-full aspect-video overflow-hidden ${onOpenViewer ? 'cursor-zoom-in' : ''}`}
|
||||||
srcSet={srcSet}
|
onClick={onOpenViewer}
|
||||||
sizes="(min-width: 1280px) 1280px, (min-width: 768px) 90vw, 100vw"
|
role={onOpenViewer ? 'button' : undefined}
|
||||||
alt={artwork?.title ?? 'Artwork'}
|
aria-label={onOpenViewer ? 'View fullscreen' : undefined}
|
||||||
className={`absolute inset-0 h-full w-full object-contain transition-opacity duration-500 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
tabIndex={onOpenViewer ? 0 : undefined}
|
||||||
loading="eager"
|
onKeyDown={onOpenViewer ? (e) => e.key === 'Enter' && onOpenViewer() : undefined}
|
||||||
decoding="async"
|
|
||||||
onLoad={() => setIsLoaded(true)}
|
|
||||||
onError={(event) => {
|
|
||||||
event.currentTarget.src = FALLBACK_LG
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Prev arrow */}
|
|
||||||
{hasPrev && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Previous artwork"
|
|
||||||
onClick={(e) => { e.stopPropagation(); onPrev?.(); }}
|
|
||||||
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/50 text-white/70 backdrop-blur-sm ring-1 ring-white/15 shadow-lg opacity-50 hover:opacity-100 focus:opacity-100 transition-opacity duration-150"
|
|
||||||
>
|
>
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
<img
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
src={md}
|
||||||
</svg>
|
alt={artwork?.title ?? 'Artwork'}
|
||||||
</button>
|
className="absolute inset-0 h-full w-full object-contain"
|
||||||
)}
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Next arrow */}
|
<img
|
||||||
{hasNext && (
|
src={lg}
|
||||||
<button
|
srcSet={srcSet}
|
||||||
type="button"
|
sizes="(min-width: 1280px) 1280px, (min-width: 768px) 90vw, 100vw"
|
||||||
aria-label="Next artwork"
|
alt={artwork?.title ?? 'Artwork'}
|
||||||
onClick={(e) => { e.stopPropagation(); onNext?.(); }}
|
className={`absolute inset-0 h-full w-full object-contain transition-opacity duration-500 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
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"
|
loading="eager"
|
||||||
>
|
decoding="async"
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
onLoad={() => setIsLoaded(true)}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
onError={(event) => {
|
||||||
</svg>
|
event.currentTarget.src = FALLBACK_LG
|
||||||
</button>
|
}}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
|
{onOpenViewer && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="View fullscreen"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onOpenViewer(); }}
|
||||||
|
className="absolute bottom-3 right-3 flex h-9 w-9 items-center justify-center rounded-full bg-black/50 text-white/80 backdrop-blur-sm ring-1 ring-white/15 opacity-0 hover:opacity-100 focus:opacity-100 [div:hover_&]:opacity-100 transition-opacity duration-150 shadow-lg"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasRealArtworkImage && (
|
||||||
|
<div className="pointer-events-none absolute inset-x-8 -bottom-5 h-10 rounded-full bg-accent/25 blur-2xl" />
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
|
||||||
{onOpenViewer && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="View fullscreen"
|
|
||||||
onClick={(e) => { e.stopPropagation(); onOpenViewer(); }}
|
|
||||||
className="absolute bottom-3 right-3 flex h-9 w-9 items-center justify-center rounded-full bg-black/50 text-white/80 backdrop-blur-sm ring-1 ring-white/15 opacity-0 hover:opacity-100 focus:opacity-100 [div:hover_&]:opacity-100 transition-opacity duration-150 shadow-lg"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasRealArtworkImage && (
|
|
||||||
<div className="pointer-events-none absolute inset-x-8 -bottom-5 h-10 rounded-full bg-accent/25 blur-2xl" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</figure>
|
</figure>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
6
resources/views/legacy/[#6223] Red Cloud XP.txt
Normal file
6
resources/views/legacy/[#6223] Red Cloud XP.txt
Normal 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>
|
||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user