Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -47,7 +47,11 @@ final class ArtworkPageController extends Controller
return response(view('errors.410'), 410);
}
if (! $raw->is_public || ! $raw->is_approved) {
if (! $raw->is_public
|| ! $raw->is_approved
|| (string) ($raw->visibility ?? '') === Artwork::VISIBILITY_PRIVATE
|| $raw->published_at === null
|| $raw->published_at->isFuture()) {
// Artwork exists but is private/unapproved → 403 Forbidden.
// Show other public artworks by the same creator as recovery suggestions.
$suggestions = app(ErrorSuggestionService::class);
@@ -63,8 +67,7 @@ final class ArtworkPageController extends Controller
->with('user')
->where('user_id', $raw->user_id)
->where('id', '!=', $raw->id)
->public()
->published()
->catalogVisible()
->limit(6)
->get()
->map(function (Artwork $a) {
@@ -185,6 +188,9 @@ final class ArtworkPageController extends Controller
'id' => (int) $item->id,
'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'author' => html_entity_decode((string) ($item->group?->name ?: $item->user?->name ?: $item->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'author_id' => (int) ($item->user?->id ?? 0),
'publisher_type' => $item->group ? 'group' : 'user',
'publisher_id' => $item->group ? (int) $item->group->id : (int) ($item->user?->id ?? 0),
'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]),
'thumb' => $md['url'] ?? null,
'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w',

View File

@@ -88,12 +88,12 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
$ttl = self::SORT_TTL_MAP[$sort] ?? 300;
$artworks = Cache::remember(
"browse.all.{$sort}.{$page}",
"browse.all.catalog-visible.v2.{$sort}.{$page}",
$ttl,
fn () => Artwork::search('')->options([
fn () => $this->search->searchWithThumbnailPreference([
'filter' => 'is_public = true AND is_approved = true',
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
])->paginate($perPage)
], $perPage, false, $page)
);
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$seo = $this->buildPaginationSeo($request, url('/browse'), $artworks);
@@ -150,12 +150,12 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
$normalizedPath = trim((string) $path, '/');
if ($normalizedPath === '') {
$artworks = Cache::remember(
"gallery.ct.{$contentSlug}.{$sort}.{$page}",
"gallery.ct.catalog-visible.v2.{$contentSlug}.{$sort}.{$page}",
$ttl,
fn () => Artwork::search('')->options([
fn () => $this->search->searchWithThumbnailPreference([
'filter' => 'is_public = true AND is_approved = true AND content_type = "' . $contentSlug . '"',
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
])->paginate($perPage)
], $perPage, false, $page)
);
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug), $artworks);
@@ -197,12 +197,12 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
->implode(' OR ');
$artworks = Cache::remember(
'gallery.cat.' . md5($contentSlug . '|' . implode('|', $categorySlugs)) . ".{$sort}.{$page}",
'gallery.cat.catalog-visible.v2.' . md5($contentSlug . '|' . implode('|', $categorySlugs)) . ".{$sort}.{$page}",
$ttl,
fn () => Artwork::search('')->options([
fn () => $this->search->searchWithThumbnailPreference([
'filter' => 'is_public = true AND is_approved = true AND (' . $categoryFilter . ')',
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
])->paginate($perPage)
], $perPage, false, $page)
);
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug . '/' . strtolower($category->full_slug_path)), $artworks);

View File

@@ -171,8 +171,7 @@ final class DiscoverController extends Controller
$today = now();
$artworks = Artwork::query()
->public()
->published()
->catalogVisible()
->with([
'user:id,name',
'user.profile:user_id,avatar_hash',
@@ -209,16 +208,14 @@ final class DiscoverController extends Controller
if ($hasStats) {
$sub = Artwork::query()
->public()
->published()
->catalogVisible()
->join('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->where('artworks.published_at', '>=', now()->subDays(90))
->selectRaw('artworks.user_id, SUM(artwork_stats.views) as recent_views, MAX(artworks.published_at) as latest_published')
->groupBy('artworks.user_id');
} else {
$sub = Artwork::query()
->public()
->published()
->catalogVisible()
->where('published_at', '>=', now()->subDays(90))
->selectRaw('user_id, COUNT(*) as recent_views, MAX(published_at) as latest_published')
->groupBy('user_id');
@@ -346,8 +343,7 @@ final class DiscoverController extends Controller
$artworks = Cache::remember($cacheKey, 60, function () use ($user, $followingIds, $perPage): \Illuminate\Pagination\LengthAwarePaginator {
return Artwork::query()
->public()
->published()
->catalogVisible()
->with(['user:id,name,username', 'categories:id,name,slug,content_type_id,parent_id,sort_order'])
->whereIn('user_id', $followingIds)
->orderMissingThumbnailsLast()
@@ -414,8 +410,7 @@ final class DiscoverController extends Controller
private function fallbackFreshFromDatabase(int $perPage)
{
return Artwork::query()
->public()
->published()
->catalogVisible()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
@@ -434,8 +429,7 @@ final class DiscoverController extends Controller
$cutoff = now()->subDays($windowDays)->startOfDay();
return Artwork::query()
->public()
->published()
->catalogVisible()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
@@ -460,8 +454,7 @@ final class DiscoverController extends Controller
$cutoff = now()->subDays($windowDays)->startOfDay();
return Artwork::query()
->public()
->published()
->catalogVisible()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
@@ -488,8 +481,7 @@ final class DiscoverController extends Controller
$recentActivity = $this->risingRecentActivitySubquery();
return Artwork::query()
->public()
->published()
->catalogVisible()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
@@ -552,6 +544,7 @@ final class DiscoverController extends Controller
}
$byId = Artwork::query()
->catalogVisible()
->whereIn('id', $ids)
->with([
'user:id,name,username',
@@ -571,6 +564,10 @@ final class DiscoverController extends Controller
return $this->presentArtwork($full);
}
if ($id > 0) {
return null;
}
return (object) [
'id' => $item->id ?? 0,
'name' => $item->title ?? $item->name ?? 'Untitled',
@@ -588,7 +585,7 @@ final class DiscoverController extends Controller
'width' => isset($item->width) && $item->width ? (int) $item->width : null,
'height' => isset($item->height) && $item->height ? (int) $item->height : null,
];
})
})->filter()->values()
);
}
@@ -680,8 +677,7 @@ final class DiscoverController extends Controller
}
return Artwork::query()
->public()
->published()
->catalogVisible()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash', 'stats:artwork_id,views,favorites,comments_count,heat_score'])
->whereIn('user_id', $followingIds)
->where('published_at', '>=', now()->subDays(30))

View File

@@ -6,6 +6,8 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\User;
use App\Services\ArtworkSearchService;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\EarlyGrowth\EarlyGrowth;
@@ -77,10 +79,12 @@ final class ExploreController extends Controller
$page = max(1, (int) $request->query('page', 1));
$ttl = self::SORT_TTL[$sort] ?? 300;
$cacheVersion = $this->cacheVersion();
$filter = $this->buildExploreFilterExpression($request);
$cacheSuffix = $this->requestCacheSuffix($request);
$artworks = Cache::remember("explore.all.v{$cacheVersion}.{$sort}.{$page}", $ttl, fn () =>
$artworks = Cache::remember("explore.all.v{$cacheVersion}.{$cacheSuffix}.{$page}", $ttl, fn () =>
$this->search->searchWithThumbnailPreference([
'filter' => 'is_public = true AND is_approved = true',
'filter' => $filter,
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
], $perPage, false, $page)
);
@@ -148,13 +152,10 @@ final class ExploreController extends Controller
$page = max(1, (int) $request->query('page', 1));
$ttl = self::SORT_TTL[$sort] ?? 300;
$cacheVersion = $this->cacheVersion();
$filter = $this->buildExploreFilterExpression($request, $isAll ? null : $resolvedTypeSlug);
$cacheSuffix = $this->requestCacheSuffix($request);
$filter = 'is_public = true AND is_approved = true';
if (!$isAll) {
$filter .= ' AND content_type = "' . $type . '"';
}
$artworks = Cache::remember("explore.{$resolvedTypeSlug}.v{$cacheVersion}.{$sort}.{$page}", $ttl, fn () =>
$artworks = Cache::remember("explore.{$resolvedTypeSlug}.v{$cacheVersion}.{$cacheSuffix}.{$page}", $ttl, fn () =>
$this->search->searchWithThumbnailPreference([
'filter' => $filter,
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
@@ -288,11 +289,122 @@ final class ExploreController extends Controller
return max(12, min($v, 80));
}
private function requestCacheSuffix(Request $request): string
{
$query = $request->query();
unset($query['grid'], $query['page']);
ksort($query);
return md5(json_encode($query, JSON_THROW_ON_ERROR));
}
private function cacheVersion(): int
{
return max(1, (int) Cache::get('explore.cache.version', 1));
}
private function buildExploreFilterExpression(Request $request, ?string $contentType = null): string
{
$filterParts = [
'is_public = true',
'is_approved = true',
];
if ($contentType !== null && $contentType !== '') {
$filterParts[] = 'content_type = "' . addslashes($contentType) . '"';
}
$orientation = strtolower(trim((string) $request->query('orientation', '')));
if (in_array($orientation, ['landscape', 'portrait', 'square'], true)) {
$filterParts[] = 'orientation = "' . addslashes($orientation) . '"';
}
$resolution = $this->resolutionFilterValue((string) $request->query('resolution', ''));
if ($resolution !== null) {
$filterParts[] = 'resolution = "' . addslashes($resolution) . '"';
}
$dateFrom = $this->normalizeDateQuery((string) $request->query('date_from', ''));
if ($dateFrom !== null) {
$filterParts[] = 'created_at >= "' . $dateFrom . '"';
}
$dateTo = $this->normalizeDateQuery((string) $request->query('date_to', ''));
if ($dateTo !== null) {
$filterParts[] = 'created_at <= "' . $dateTo . '"';
}
$authorFilter = $this->authorFilterExpression((string) $request->query('author', ''));
if ($authorFilter !== null) {
$filterParts[] = $authorFilter;
}
return implode(' AND ', $filterParts);
}
private function resolutionFilterValue(string $resolution): ?string
{
return match (strtolower(trim($resolution))) {
'hd' => '1280x720',
'fhd' => '1920x1080',
'2k' => '2560x1440',
'4k' => '3840x2160',
default => null,
};
}
private function normalizeDateQuery(string $value): ?string
{
$value = trim($value);
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
return null;
}
return $value;
}
private function authorFilterExpression(string $author): ?string
{
$author = trim($author);
if ($author === '') {
return null;
}
$userIds = User::query()
->where(function ($query) use ($author): void {
$query->where('username', 'like', '%' . $author . '%')
->orWhere('name', 'like', '%' . $author . '%');
})
->limit(20)
->pluck('id');
$groupIds = Group::query()
->where(function ($query) use ($author): void {
$query->where('name', 'like', '%' . $author . '%')
->orWhere('slug', 'like', '%' . $author . '%');
})
->limit(20)
->pluck('id');
$clauses = [];
foreach ($userIds as $userId) {
$clauses[] = '(author_id = ' . (int) $userId . ' AND published_as_type = "user")';
}
foreach ($groupIds as $groupId) {
$clauses[] = '(author_id = ' . (int) $groupId . ' AND published_as_type = "group")';
}
if ($clauses === []) {
return 'id = 0';
}
return '(' . implode(' OR ', $clauses) . ')';
}
private function filterBrowsableArtworks(AbstractPaginator $paginator): AbstractPaginator
{
$paginator->setCollection(

View File

@@ -32,6 +32,7 @@ final class HelpCenterPageController extends Controller
'links' => [
'studio_help' => route('help.studio'),
'upload_help' => route('help.upload'),
'help_worlds' => route('help.worlds'),
'groups_documentation' => route('help.groups'),
'groups_quickstart' => route('help.groups.quickstart'),
'groups_faq' => route('help.groups.faq'),
@@ -42,10 +43,14 @@ final class HelpCenterPageController extends Controller
'studio_home' => route('studio.index'),
'studio_content' => route('studio.content'),
'studio_artworks' => route('studio.artworks'),
'studio_worlds' => route('studio.worlds.index'),
'studio_worlds_create' => route('studio.worlds.create'),
'studio_cards' => route('studio.cards.index'),
'studio_drafts' => route('studio.drafts'),
'cards_create' => route('studio.cards.create'),
'upload' => route('upload'),
'worlds_index' => route('worlds.index'),
'create_world' => route('worlds.create.redirect'),
'cards_index' => route('cards.index'),
'help_cards' => route('help.cards'),
'help_profile' => route('help.profile'),

View File

@@ -21,6 +21,7 @@ class LeaderboardPageController extends Controller
'artworks', Leaderboard::TYPE_ARTWORK => Leaderboard::TYPE_ARTWORK,
'groups', Leaderboard::TYPE_GROUP => Leaderboard::TYPE_GROUP,
'stories', Leaderboard::TYPE_STORY => Leaderboard::TYPE_STORY,
'worlds', Leaderboard::TYPE_WORLD => Leaderboard::TYPE_WORLD,
default => Leaderboard::TYPE_CREATOR,
};
@@ -28,6 +29,7 @@ class LeaderboardPageController extends Controller
Leaderboard::TYPE_GROUP => 'Top Groups Leaderboard — Skinbase',
Leaderboard::TYPE_STORY => 'Top Stories Leaderboard — Skinbase',
Leaderboard::TYPE_ARTWORK => 'Top Artworks Leaderboard — Skinbase',
Leaderboard::TYPE_WORLD => 'Top Worlds Leaderboard — Skinbase',
default => 'Top Creators & Artworks Leaderboard — Skinbase',
};
@@ -35,7 +37,8 @@ class LeaderboardPageController extends Controller
Leaderboard::TYPE_GROUP => 'Track the leading groups across Skinbase by daily, weekly, monthly, and all-time performance.',
Leaderboard::TYPE_STORY => 'Track the leading stories across Skinbase by daily, weekly, monthly, and all-time performance.',
Leaderboard::TYPE_ARTWORK => 'Track the leading artworks across Skinbase by daily, weekly, monthly, and all-time performance.',
default => 'Track the leading creators, groups, artworks, and stories across Skinbase by daily, weekly, monthly, and all-time performance.',
Leaderboard::TYPE_WORLD => 'Track the leading Worlds across Skinbase by daily, weekly, monthly, and all-time performance.',
default => 'Track the leading creators, groups, artworks, stories, and Worlds across Skinbase by daily, weekly, monthly, and all-time performance.',
};
return Inertia::render('Leaderboard/LeaderboardPage', [

View File

@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkListResource;
use App\Services\ArtworkSearchService;
use App\Services\GroupDiscoveryService;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Http\Request;
use Illuminate\View\View;
use cPad\Plugins\News\Models\NewsArticle;
@@ -62,9 +64,18 @@ final class SearchController extends Controller
$groupResultCount = $groupResults->count();
$newsResultCount = $newsResults->count();
$hasAnyResults = $resultCount > 0 || $groupResultCount > 0 || $newsResultCount > 0;
$galleryArtworks = collect(method_exists($artworks, 'items') ? $artworks->items() : $artworks)
->map(fn ($art) => $this->mapArtworkCard($art))
->values();
$galleryItems = method_exists($artworks, 'getCollection')
? $artworks->getCollection()
: new EloquentCollection(collect($artworks)->all());
$galleryItems->loadMissing(['user.profile', 'group', 'categories.contentType']);
$galleryArtworks = $galleryItems
->map(fn ($artwork) => (new ArtworkListResource($artwork))->resolve($request))
->values()
->all();
$galleryNextPageUrl = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
return view('search.index', [
@@ -87,28 +98,4 @@ final class SearchController extends Controller
'page_robots' => 'noindex,follow',
]);
}
private function mapArtworkCard(mixed $artwork): array
{
return [
'id' => $artwork->id ?? null,
'name' => $artwork->name ?? null,
'thumb' => $artwork->thumb_url ?? $artwork->thumb ?? null,
'thumb_srcset' => $artwork->thumb_srcset ?? null,
'uname' => $artwork->uname ?? '',
'username' => $artwork->username ?? '',
'avatar_url' => $artwork->avatar_url ?? null,
'profile_url' => $artwork->profile_url ?? null,
'published_as_type' => $artwork->published_as_type ?? null,
'publisher' => $artwork->publisher ?? null,
'category_name' => $artwork->category_name ?? '',
'category_slug' => $artwork->category_slug ?? '',
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,
'views' => $artwork->views ?? null,
'likes' => $artwork->likes ?? null,
'downloads' => $artwork->downloads ?? null,
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\World;
use App\Services\Worlds\WorldService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class WorldController extends Controller
{
public function __construct(private readonly WorldService $worlds)
{
}
public function index(Request $request): Response
{
$payload = $this->worlds->publicIndexPayload($request->user());
$seo = app(SeoFactory::class)->collectionListing(
'Worlds — Skinbase Nova',
$payload['description'],
route('worlds.index'),
)->toArray();
return Inertia::render('World/WorldIndex', array_merge($payload, [
'seo' => $seo,
]))->rootView('collections');
}
public function show(Request $request, World $world): Response
{
abort_unless($world->isPubliclyVisible(), 404);
$payload = $this->worlds->publicShowPayload($world, $request->user());
$seo = app(SeoFactory::class)->collectionPage(
$world->seo_title ?: ($world->title . ' — Skinbase Nova'),
$world->seo_description ?: ($world->summary ?: $world->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'),
route('worlds.show', ['world' => $world->slug]),
$world->ogImageUrl(),
)->toArray();
return Inertia::render('World/WorldShow', array_merge($payload, [
'seo' => $seo,
]))->rootView('collections');
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class WorldsHelpPageController extends Controller
{
public function __invoke(Request $request): Response
{
$canonical = route('help.worlds');
$seo = app(SeoFactory::class)
->collectionPage(
'Worlds Help — Skinbase',
'Learn how Worlds work on Skinbase Nova, including editorial purpose, attached content, section control, preview, publishing, recurrence, and homepage promotion.',
$canonical,
)
->toArray();
$seo['og_type'] = 'article';
return Inertia::render('Help/WorldsHelpPage', [
'title' => 'Worlds Help',
'description' => 'A complete guide to creating, attaching content to, previewing, and publishing Worlds on Skinbase Nova.',
'seo' => $seo,
'links' => [
'help_home' => route('help'),
'studio_help' => route('help.studio'),
'upload_help' => route('help.upload'),
'help_cards' => route('help.cards'),
'groups_help' => route('help.groups'),
'worlds_index' => route('worlds.index'),
'create_world' => route('worlds.create.redirect'),
'studio_worlds' => route('studio.worlds.index'),
'studio_worlds_create' => route('studio.worlds.create'),
'open_studio' => route('studio.index'),
'contact_support' => route('contact.show'),
'report_issue' => route('bug-report'),
],
'auth' => [
'signed_in' => $request->user() !== null,
],
])->rootView('collections');
}
}