Implement creator studio and upload updates

This commit is contained in:
2026-04-04 10:12:02 +02:00
parent 1da7d3bf88
commit 0b216b7ecd
15107 changed files with 31206 additions and 626514 deletions

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Services\Sitemaps\AbstractSitemapBuilder;
use App\Services\Sitemaps\ShardableSitemapBuilder;
use App\Services\Sitemaps\SitemapUrl;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
abstract class AbstractIdShardableSitemapBuilder extends AbstractSitemapBuilder implements ShardableSitemapBuilder
{
/**
* @return Builder<Model>
*/
abstract protected function query(): Builder;
abstract protected function shardConfigKey(): string;
abstract protected function mapRecord(Model $record): ?SitemapUrl;
public function items(): array
{
return $this->query()
->orderBy($this->idColumn())
->cursor()
->map(fn (Model $record): ?SitemapUrl => $this->mapRecord($record))
->filter()
->values()
->all();
}
public function lastModified(): ?DateTimeInterface
{
return $this->newest(...array_map(
fn (SitemapUrl $item): ?DateTimeInterface => $item->lastModified,
$this->items(),
));
}
public function totalItems(): int
{
return (clone $this->query())->count();
}
public function shardSize(): int
{
return max(1, (int) \data_get(\config('sitemaps.shards', []), $this->shardConfigKey() . '.size', 10000));
}
public function itemsForShard(int $shard): array
{
$window = $this->shardWindow($shard);
if ($window === null) {
return [];
}
return $this->applyShardWindow($window['from'], $window['to'])
->get()
->map(fn (Model $record): ?SitemapUrl => $this->mapRecord($record))
->filter()
->values()
->all();
}
public function lastModifiedForShard(int $shard): ?DateTimeInterface
{
return $this->newest(...array_map(
fn (SitemapUrl $item): ?DateTimeInterface => $item->lastModified,
$this->itemsForShard($shard),
));
}
/**
* @return array{from: int, to: int}|null
*/
protected function shardWindow(int $shard): ?array
{
if ($shard < 1) {
return null;
}
$size = $this->shardSize();
$current = 0;
$from = null;
$to = null;
$windowQuery = (clone $this->query())
->setEagerLoads([])
->select([$this->idColumn()])
->orderBy($this->idColumn());
foreach ($windowQuery->cursor() as $record) {
$current++;
if ((int) ceil($current / $size) !== $shard) {
continue;
}
$recordId = (int) $record->getAttribute($this->idColumn());
$from ??= $recordId;
$to = $recordId;
}
if ($from === null || $to === null) {
return null;
}
return ['from' => $from, 'to' => $to];
}
/**
* @return Builder<Model>
*/
protected function applyShardWindow(int $from, int $to): Builder
{
return (clone $this->query())
->whereBetween($this->qualifiedIdColumn(), [$from, $to])
->orderBy($this->idColumn());
}
protected function idColumn(): string
{
return 'id';
}
protected function qualifiedIdColumn(): string
{
return $this->idColumn();
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Models\Artwork;
use App\Services\Sitemaps\SitemapUrl;
use App\Services\Sitemaps\SitemapUrlBuilder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
final class ArtworksSitemapBuilder extends AbstractIdShardableSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'artworks';
}
protected function shardConfigKey(): string
{
return 'artworks';
}
protected function mapRecord(Model $record): ?SitemapUrl
{
return $this->urls->artwork($record);
}
protected function query(): Builder
{
return Artwork::query()
->public()
->published();
}
protected function qualifiedIdColumn(): string
{
return 'artworks.id';
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Models\NovaCard;
use App\Services\Sitemaps\SitemapUrl;
use App\Services\Sitemaps\SitemapUrlBuilder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
final class CardsSitemapBuilder extends AbstractIdShardableSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'cards';
}
protected function shardConfigKey(): string
{
return 'cards';
}
protected function mapRecord(Model $record): ?SitemapUrl
{
return $this->urls->card($record);
}
protected function query(): Builder
{
return NovaCard::query()
->publiclyVisible()
->orderBy('id');
}
protected function qualifiedIdColumn(): string
{
return 'nova_cards.id';
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Models\Category;
use App\Models\ContentType;
use App\Services\Sitemaps\AbstractSitemapBuilder;
use App\Services\Sitemaps\SitemapUrlBuilder;
use DateTimeInterface;
final class CategoriesSitemapBuilder extends AbstractSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'categories';
}
public function items(): array
{
$items = [$this->urls->categoryDirectory()];
$contentTypes = ContentType::query()
->whereIn('slug', $this->contentTypeSlugs())
->ordered()
->get();
foreach ($contentTypes as $contentType) {
$items[] = $this->urls->contentType($contentType);
}
$categories = Category::query()
->with('contentType')
->active()
->whereHas('contentType', fn ($query) => $query->whereIn('slug', $this->contentTypeSlugs()))
->orderBy('content_type_id')
->orderBy('parent_id')
->orderBy('sort_order')
->orderBy('name')
->get();
foreach ($categories as $category) {
$items[] = $this->urls->category($category);
}
return $items;
}
public function lastModified(): ?DateTimeInterface
{
return $this->dateTime(Category::query()
->active()
->max('updated_at'));
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Models\Collection;
use App\Services\Sitemaps\SitemapUrl;
use App\Services\Sitemaps\SitemapUrlBuilder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
final class CollectionsSitemapBuilder extends AbstractIdShardableSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'collections';
}
protected function shardConfigKey(): string
{
return 'collections';
}
protected function mapRecord(Model $record): ?SitemapUrl
{
return $this->urls->collection($record);
}
protected function query(): Builder
{
return Collection::query()
->with('user:id,username')
->public()
->whereNull('canonical_collection_id')
->orderBy('id');
}
protected function qualifiedIdColumn(): string
{
return 'collections.id';
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Services\Sitemaps\AbstractSitemapBuilder;
use App\Services\Sitemaps\SitemapUrlBuilder;
use DateTimeInterface;
use cPad\Plugins\Forum\Models\ForumBoard;
use cPad\Plugins\Forum\Models\ForumCategory;
final class ForumCategoriesSitemapBuilder extends AbstractSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'forum-categories';
}
public function items(): array
{
$items = [];
foreach (ForumCategory::query()->active()->ordered()->get() as $category) {
$items[] = $this->urls->forumCategory($category);
}
foreach (ForumBoard::query()->active()->ordered()->get() as $board) {
$items[] = $this->urls->forumBoard($board);
}
return $items;
}
public function lastModified(): ?DateTimeInterface
{
return $this->newest(
ForumCategory::query()->active()->max('updated_at'),
ForumBoard::query()->active()->max('updated_at'),
);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Services\Sitemaps\AbstractSitemapBuilder;
use App\Services\Sitemaps\SitemapUrlBuilder;
use DateTimeInterface;
final class ForumIndexSitemapBuilder extends AbstractSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'forum-index';
}
public function items(): array
{
return [$this->urls->forumIndex()];
}
public function lastModified(): ?DateTimeInterface
{
return null;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Services\Sitemaps\SitemapUrl;
use App\Services\Sitemaps\SitemapUrlBuilder;
use cPad\Plugins\Forum\Models\ForumTopic;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
final class ForumThreadsSitemapBuilder extends AbstractIdShardableSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'forum-threads';
}
protected function shardConfigKey(): string
{
return 'forum-threads';
}
protected function mapRecord(Model $record): ?SitemapUrl
{
return $this->urls->forumTopic($record);
}
protected function query(): Builder
{
return ForumTopic::query()
->visible()
->whereHas('board', fn ($query) => $query->active())
->orderBy('id');
}
protected function qualifiedIdColumn(): string
{
return 'forum_topics.id';
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Services\Sitemaps\AbstractSitemapBuilder;
use App\Services\Sitemaps\GoogleNewsSitemapUrl;
use DateTimeInterface;
use cPad\Plugins\News\Models\NewsArticle;
final class GoogleNewsSitemapBuilder extends AbstractSitemapBuilder
{
public function name(): string
{
return (string) \config('sitemaps.news.google_variant_name', 'news-google');
}
public function items(): array
{
return NewsArticle::query()
->published()
->where('published_at', '>=', now()->subHours(max(1, (int) \config('sitemaps.news.google_lookback_hours', 48))))
->orderByDesc('published_at')
->limit(max(1, (int) \config('sitemaps.news.google_max_items', 1000)))
->get()
->map(function (NewsArticle $article): ?GoogleNewsSitemapUrl {
if (trim((string) $article->slug) === '' || $article->published_at === null) {
return null;
}
return new GoogleNewsSitemapUrl(
route('news.show', ['slug' => $article->slug]),
trim((string) $article->title),
$article->published_at,
(string) \config('sitemaps.news.google_publication_name', 'Skinbase Nova'),
(string) \config('sitemaps.news.google_language', 'en'),
);
})
->filter(fn (?GoogleNewsSitemapUrl $item): bool => $item !== null && $item->title !== '')
->values()
->all();
}
public function lastModified(): ?DateTimeInterface
{
return $this->dateTime(NewsArticle::query()
->published()
->where('published_at', '>=', now()->subHours(max(1, (int) \config('sitemaps.news.google_lookback_hours', 48))))
->max('published_at'));
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Services\Sitemaps\AbstractSitemapBuilder;
use App\Services\Sitemaps\SitemapUrl;
use App\Services\Sitemaps\SitemapUrlBuilder;
use DateTimeInterface;
use cPad\Plugins\News\Models\NewsArticle;
final class NewsSitemapBuilder extends AbstractSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'news';
}
public function items(): array
{
return NewsArticle::query()
->published()
->orderBy('id')
->cursor()
->map(fn (NewsArticle $article): ?SitemapUrl => $this->urls->news($article))
->filter()
->values()
->all();
}
public function lastModified(): ?DateTimeInterface
{
return $this->dateTime(NewsArticle::query()
->published()
->max('updated_at'));
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Models\Page;
use App\Services\Sitemaps\AbstractSitemapBuilder;
use App\Services\Sitemaps\SitemapUrlBuilder;
use DateTimeInterface;
final class StaticPagesSitemapBuilder extends AbstractSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'static-pages';
}
public function items(): array
{
$items = [
$this->urls->staticRoute('/'),
$this->urls->staticRoute('/faq'),
$this->urls->staticRoute('/rules-and-guidelines'),
$this->urls->staticRoute('/privacy-policy'),
$this->urls->staticRoute('/terms-of-service'),
$this->urls->staticRoute('/staff'),
];
$marketingPages = Page::query()
->published()
->whereIn('slug', ['about', 'help'])
->get()
->keyBy('slug');
if ($marketingPages->has('about')) {
$items[] = $this->urls->page($marketingPages['about'], '/about');
}
if ($marketingPages->has('help')) {
$items[] = $this->urls->page($marketingPages['help'], '/help');
}
$excluded = array_values((array) config('sitemaps.static_page_excluded_slugs', []));
foreach (Page::query()->published()->whereNotIn('slug', $excluded)->orderBy('slug')->get() as $page) {
$items[] = $this->urls->page($page, '/pages/' . $page->slug);
}
return $items;
}
public function lastModified(): ?DateTimeInterface
{
return $this->dateTime(Page::query()->published()->max('updated_at'));
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Models\Story;
use App\Services\Sitemaps\SitemapUrl;
use App\Services\Sitemaps\SitemapUrlBuilder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
final class StoriesSitemapBuilder extends AbstractIdShardableSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'stories';
}
protected function shardConfigKey(): string
{
return 'stories';
}
protected function mapRecord(Model $record): ?SitemapUrl
{
return $this->urls->story($record);
}
protected function query(): Builder
{
return Story::query()
->published()
->orderBy('id');
}
protected function qualifiedIdColumn(): string
{
return 'stories.id';
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Models\Tag;
use App\Services\Sitemaps\AbstractSitemapBuilder;
use App\Services\Sitemaps\SitemapUrl;
use App\Services\Sitemaps\SitemapUrlBuilder;
use DateTimeInterface;
final class TagsSitemapBuilder extends AbstractSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'tags';
}
public function items(): array
{
return Tag::query()
->where('is_active', true)
->where('usage_count', '>', 0)
->whereHas('artworks', fn ($query) => $query->public()->published())
->orderByDesc('usage_count')
->orderBy('slug')
->get()
->map(fn (Tag $tag): SitemapUrl => $this->urls->tag($tag))
->values()
->all();
}
public function lastModified(): ?DateTimeInterface
{
return $this->dateTime(Tag::query()
->where('is_active', true)
->where('usage_count', '>', 0)
->max('updated_at'));
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Models\Artwork;
use App\Models\Collection;
use App\Models\NovaCard;
use App\Models\Story;
use App\Models\User;
use App\Services\Sitemaps\SitemapUrl;
use App\Services\Sitemaps\SitemapUrlBuilder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
final class UsersSitemapBuilder extends AbstractIdShardableSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'users';
}
protected function shardConfigKey(): string
{
return 'users';
}
protected function mapRecord(Model $record): ?SitemapUrl
{
return $this->urls->profile($record, $record->updated_at);
}
protected function query(): Builder
{
return User::query()
->where('is_active', true)
->whereNull('deleted_at')
->whereNotNull('username')
->where('username', '!=', '')
->where(function (Builder $builder): void {
$builder->whereExists(
Artwork::query()
->selectRaw('1')
->public()
->published()
->whereColumn('artworks.user_id', 'users.id')
)->orWhereExists(
Collection::query()
->selectRaw('1')
->public()
->whereNull('canonical_collection_id')
->whereColumn('collections.user_id', 'users.id')
)->orWhereExists(
NovaCard::query()
->selectRaw('1')
->publiclyVisible()
->whereColumn('nova_cards.user_id', 'users.id')
)->orWhereExists(
Story::query()
->selectRaw('1')
->published()
->whereColumn('stories.creator_id', 'users.id')
);
});
}
protected function qualifiedIdColumn(): string
{
return 'users.id';
}
}