login update

This commit is contained in:
2026-03-05 11:24:37 +01:00
parent 5a33ca55a1
commit f6772f673b
67 changed files with 10640 additions and 116 deletions

View File

@@ -13,18 +13,66 @@ use Illuminate\View\View;
/**
* RssFeedController
*
* GET /rss-feeds info page listing available feeds
* GET /rss/latest-uploads.xml all published artworks
* GET /rss/latest-skins.xml skins only
* GET /rss/latest-wallpapers.xml wallpapers only
* GET /rss/latest-photos.xml photography only
* GET /rss-feeds info page listing all available feeds
* GET /rss/latest-uploads.xml all published artworks (legacy)
* GET /rss/latest-skins.xml skins only (legacy)
* GET /rss/latest-wallpapers.xml wallpapers only (legacy)
* GET /rss/latest-photos.xml photography only (legacy)
*
* Nova feeds live in App\Http\Controllers\RSS\*.
*/
final class RssFeedController extends Controller
{
/** Number of items per feed. */
/** Number of items per legacy feed. */
private const FEED_LIMIT = 25;
/** Feed definitions shown on the info page. */
/**
* Grouped feed definitions shown on the /rss-feeds info page.
* Each group has a 'label' and an array of 'feeds' with title + url.
*/
public const FEED_GROUPS = [
'global' => [
'label' => 'Global',
'feeds' => [
['title' => 'Latest Artworks', 'url' => '/rss', 'description' => 'All new artworks across the platform.'],
],
],
'discover' => [
'label' => 'Discover',
'feeds' => [
['title' => 'Fresh Uploads', 'url' => '/rss/discover/fresh', 'description' => 'The newest artworks just published.'],
['title' => 'Trending', 'url' => '/rss/discover/trending', 'description' => 'Most-viewed artworks over the past 7 days.'],
['title' => 'Rising', 'url' => '/rss/discover/rising', 'description' => 'Artworks gaining momentum right now.'],
],
],
'explore' => [
'label' => 'Explore',
'feeds' => [
['title' => 'All Artworks', 'url' => '/rss/explore/artworks', 'description' => 'Latest artworks of all types.'],
['title' => 'Wallpapers', 'url' => '/rss/explore/wallpapers', 'description' => 'Latest wallpapers.'],
['title' => 'Skins', 'url' => '/rss/explore/skins', 'description' => 'Latest skins.'],
['title' => 'Photography', 'url' => '/rss/explore/photography', 'description' => 'Latest photography.'],
['title' => 'Trending Wallpapers', 'url' => '/rss/explore/wallpapers/trending', 'description' => 'Trending wallpapers this week.'],
],
],
'blog' => [
'label' => 'Blog',
'feeds' => [
['title' => 'Blog Posts', 'url' => '/rss/blog', 'description' => 'Latest posts from the Skinbase blog.'],
],
],
'legacy' => [
'label' => 'Legacy Feeds',
'feeds' => [
['title' => 'Latest Uploads (XML)', 'url' => '/rss/latest-uploads.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Skins (XML)', 'url' => '/rss/latest-skins.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Wallpapers (XML)', 'url' => '/rss/latest-wallpapers.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Photos (XML)', 'url' => '/rss/latest-photos.xml', 'description' => 'Legacy XML feed.'],
],
],
];
/** Flat feed list kept for backward-compatibility (old view logic). */
public const FEEDS = [
'uploads' => ['title' => 'Latest Uploads', 'url' => '/rss/latest-uploads.xml'],
'skins' => ['title' => 'Latest Skins', 'url' => '/rss/latest-skins.xml'],
@@ -45,7 +93,8 @@ final class RssFeedController extends Controller
(object) ['name' => 'Home', 'url' => '/'],
(object) ['name' => 'RSS Feeds', 'url' => '/rss-feeds'],
]),
'feeds' => self::FEEDS,
'feeds' => self::FEEDS,
'feed_groups' => self::FEED_GROUPS,
'center_content' => true,
'center_max' => '3xl',
]);

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Models\StoryAuthor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
/**
* Stories filtered by author /stories/author/{username}
*/
final class StoriesAuthorController extends Controller
{
public function show(Request $request, string $username): View
{
// Resolve by linked user username first, then by author name slug
$author = StoryAuthor::whereHas('user', fn ($q) => $q->where('username', $username))
->with('user')
->first();
if (! $author) {
// Fallback: author name matches slug-style
$author = StoryAuthor::where('name', $username)->first();
}
if (! $author) {
abort(404);
}
$stories = Cache::remember('stories:author:' . $author->id . ':page:' . ($request->get('page', 1)), 300, fn () =>
Story::published()
->with('author', 'tags')
->where('author_id', $author->id)
->orderByDesc('published_at')
->paginate(12)
->withQueryString()
);
$authorName = $author->user?->username ?? $author->name;
return view('web.stories.author', [
'author' => $author,
'stories' => $stories,
'page_title' => 'Stories by ' . $authorName . ' — Skinbase',
'page_meta_description' => 'All stories and interviews by ' . $authorName . ' on Skinbase.',
'page_canonical' => url('/stories/author/' . $username),
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Stories', 'url' => '/stories'],
(object) ['name' => $authorName, 'url' => '/stories/author/' . $username],
]),
]);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Story;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
/**
* Stories listing page /stories
*/
final class StoriesController extends Controller
{
public function index(Request $request): View
{
$featured = Cache::remember('stories:featured', 300, fn () =>
Story::published()->featured()
->with('author', 'tags')
->orderByDesc('published_at')
->first()
);
$stories = Cache::remember('stories:list:page:' . ($request->get('page', 1)), 300, fn () =>
Story::published()
->with('author', 'tags')
->orderByDesc('published_at')
->paginate(12)
->withQueryString()
);
return view('web.stories.index', [
'featured' => $featured,
'stories' => $stories,
'page_title' => 'Stories — Skinbase',
'page_meta_description' => 'Artist interviews, community spotlights, tutorials and announcements from Skinbase.',
'page_canonical' => url('/stories'),
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Stories', 'url' => '/stories'],
]),
]);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Models\StoryTag;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
/**
* Stories filtered by tag /stories/tag/{tag}
*/
final class StoriesTagController extends Controller
{
public function show(Request $request, string $tag): View
{
$storyTag = StoryTag::where('slug', $tag)->firstOrFail();
$stories = Cache::remember('stories:tag:' . $tag . ':page:' . ($request->get('page', 1)), 300, fn () =>
Story::published()
->with('author', 'tags')
->whereHas('tags', fn ($q) => $q->where('stories_tags.id', $storyTag->id))
->orderByDesc('published_at')
->paginate(12)
->withQueryString()
);
return view('web.stories.tag', [
'storyTag' => $storyTag,
'stories' => $stories,
'page_title' => '#' . $storyTag->name . ' Stories — Skinbase',
'page_meta_description' => 'Stories tagged with "' . $storyTag->name . '" on Skinbase.',
'page_canonical' => url('/stories/tag/' . $storyTag->slug),
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Stories', 'url' => '/stories'],
(object) ['name' => '#' . $storyTag->name, 'url' => '/stories/tag/' . $storyTag->slug],
]),
]);
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Story;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
/**
* Single story page /stories/{slug}
*/
final class StoryController extends Controller
{
public function show(string $slug): View
{
$story = Cache::remember('stories:' . $slug, 600, fn () =>
Story::published()
->with('author', 'tags')
->where('slug', $slug)
->firstOrFail()
);
// Increment view counter (fire-and-forget, no cache invalidation needed)
Story::where('id', $story->id)->increment('views');
// Related stories: shared tags → same author → newest
$related = Cache::remember('stories:related:' . $story->id, 600, function () use ($story) {
$tagIds = $story->tags->pluck('id');
$related = collect();
if ($tagIds->isNotEmpty()) {
$related = Story::published()
->with('author', 'tags')
->whereHas('tags', fn ($q) => $q->whereIn('stories_tags.id', $tagIds))
->where('id', '!=', $story->id)
->orderByDesc('published_at')
->limit(6)
->get();
}
if ($related->count() < 3 && $story->author_id) {
$byAuthor = Story::published()
->with('author', 'tags')
->where('author_id', $story->author_id)
->where('id', '!=', $story->id)
->whereNotIn('id', $related->pluck('id'))
->orderByDesc('published_at')
->limit(6 - $related->count())
->get();
$related = $related->merge($byAuthor);
}
if ($related->count() < 3) {
$newest = Story::published()
->with('author', 'tags')
->where('id', '!=', $story->id)
->whereNotIn('id', $related->pluck('id'))
->orderByDesc('published_at')
->limit(6 - $related->count())
->get();
$related = $related->merge($newest);
}
return $related->take(6);
});
return view('web.stories.show', [
'story' => $story,
'related' => $related,
'page_title' => $story->title . ' — Skinbase Stories',
'page_meta_description' => $story->meta_excerpt,
'page_canonical' => $story->url,
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Stories', 'url' => '/stories'],
(object) ['name' => $story->title, 'url' => $story->url],
]),
]);
}
}

View File

@@ -9,6 +9,7 @@ use App\Models\ContentType;
use App\Models\Tag;
use App\Services\ArtworkSearchService;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -60,11 +61,10 @@ final class TagController extends Controller
$page = max(1, (int) $request->query('page', 1));
$artworks = $this->gridFiller->fill($artworks, 0, $page);
// Eager-load relations needed by the artwork-card component.
// Scout returns bare Eloquent models; without this, each card triggers N+1 queries.
$artworks->getCollection()->loadMissing(['user.profile']);
// Eager-load relations used by the gallery presenter and thumbnails.
$artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories']));
// Sidebar: content type links (same as browse gallery)
// Sidebar: main content type links (same as browse gallery)
$mainCategories = ContentType::orderBy('id')->get(['name', 'slug'])
->map(fn ($type) => (object) [
'id' => $type->id,
@@ -73,15 +73,76 @@ final class TagController extends Controller
'url' => '/' . strtolower($type->slug),
]);
return view('tags.show', [
'tag' => $tag,
'artworks' => $artworks,
'sort' => $sort,
'ogImage' => null,
'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase',
'page_meta_description' => 'Browse all Skinbase artworks tagged "' . $tag->name . '". Discover photography, wallpapers and skins.',
'page_canonical' => route('tags.show', $tag->slug),
'page_robots' => 'index,follow',
// Map artworks into the lightweight shape expected by the gallery React component.
$galleryCollection = $artworks->getCollection()->map(function ($a) {
$primaryCategory = $a->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($a, 'md');
$avatarUrl = \App\Support\AvatarUrl::forUser((int) ($a->user_id ?? 0), $a->user?->profile?->avatar_hash ?? null, 64);
return (object) [
'id' => $a->id,
'name' => $a->title ?? ($a->name ?? null),
'category_name' => $primaryCategory->name ?? '',
'category_slug' => $primaryCategory->slug ?? '',
'thumb_url' => $present['url'] ?? ($a->thumbUrl('md') ?? null),
'thumb_srcset' => $present['srcset'] ?? null,
'uname' => $a->user?->name ?? '',
'username' => $a->user?->username ?? '',
'avatar_url' => $avatarUrl,
'published_at' => $a->published_at ?? null,
'width' => $a->width ?? null,
'height' => $a->height ?? null,
'slug' => $a->slug ?? null,
];
})->values();
// Replace paginator collection with the gallery-shaped collection so
// the gallery.index blade will generate the expected JSON payload.
if (method_exists($artworks, 'setCollection')) {
$artworks->setCollection($galleryCollection);
}
// Determine gallery sort mapping so the gallery UI highlights the right tab.
$sortMapToGallery = [
'popular' => 'trending',
'latest' => 'latest',
'likes' => 'top-rated',
'downloads' => 'downloaded',
];
$gallerySort = $sortMapToGallery[$sort] ?? 'trending';
// Build simple pagination SEO links
$prev = method_exists($artworks, 'previousPageUrl') ? $artworks->previousPageUrl() : null;
$next = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
return view('gallery.index', [
'gallery_type' => 'tag',
'mainCategories' => $mainCategories,
'subcategories' => collect(),
'contentType' => null,
'category' => null,
'artworks' => $artworks,
'current_sort' => $gallerySort,
'sort_options' => [
['value' => 'trending', 'label' => '🔥 Trending'],
['value' => 'fresh', 'label' => '🆕 New & Hot'],
['value' => 'top-rated', 'label' => '⭐ Top Rated'],
['value' => 'latest', 'label' => '🕐 Latest'],
],
'hero_title' => $tag->name,
'hero_description' => 'Artworks tagged "' . $tag->name . '"',
'breadcrumbs' => collect([
(object) ['name' => 'Home', 'url' => '/'],
(object) ['name' => 'Tags', 'url' => route('tags.index')],
(object) ['name' => $tag->name, 'url' => route('tags.show', $tag->slug)],
]),
'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase',
'page_meta_description' => 'Browse all Skinbase artworks tagged "' . $tag->name . '".',
'page_meta_keywords' => $tag->slug . ', skinbase, artworks, tag',
'page_canonical' => route('tags.show', $tag->slug),
'page_rel_prev' => $prev,
'page_rel_next' => $next,
'page_robots' => 'index,follow',
]);
}
}