169 lines
6.6 KiB
PHP
169 lines
6.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Artwork;
|
|
use App\Models\Tag;
|
|
use App\Models\User;
|
|
use App\Models\BlogPost;
|
|
use App\Services\ThumbnailPresenter;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* ErrorSuggestionService
|
|
*
|
|
* Supplies lightweight contextual suggestions for error pages.
|
|
* All queries are cheap, results cached to TTL 5 min.
|
|
*/
|
|
final class ErrorSuggestionService
|
|
{
|
|
private const CACHE_TTL = 300; // 5 minutes
|
|
|
|
// ── Trending artworks (max 6) ─────────────────────────────────────────────
|
|
|
|
public function trendingArtworks(int $limit = 6): Collection
|
|
{
|
|
$limit = min($limit, 6);
|
|
|
|
return Cache::remember("error_suggestions.artworks.{$limit}", self::CACHE_TTL, function () use ($limit) {
|
|
return Artwork::query()
|
|
->with(['user', 'stats'])
|
|
->public()
|
|
->published()
|
|
->orderByDesc('trending_score_7d')
|
|
->limit($limit)
|
|
->get()
|
|
->map(fn (Artwork $a) => $this->artworkCard($a));
|
|
});
|
|
}
|
|
|
|
// ── Similar tags by slug prefix / Levenshtein approximation (max 10) ─────
|
|
|
|
public function similarTags(string $slug, int $limit = 10): Collection
|
|
{
|
|
$limit = min($limit, 10);
|
|
$prefix = substr($slug, 0, 3);
|
|
|
|
return Cache::remember("error_suggestions.similar_tags.{$slug}.{$limit}", self::CACHE_TTL, function () use ($slug, $limit, $prefix) {
|
|
return Tag::query()
|
|
->withCount('artworks')
|
|
->where('slug', '!=', $slug)
|
|
->where(function ($q) use ($prefix, $slug) {
|
|
$q->where('slug', 'like', $prefix . '%')
|
|
->orWhere('slug', 'like', '%' . substr($slug, -3) . '%');
|
|
})
|
|
->orderByDesc('artworks_count')
|
|
->limit($limit)
|
|
->get(['id', 'name', 'slug', 'artworks_count']);
|
|
});
|
|
}
|
|
|
|
// ── Trending tags (max 10) ────────────────────────────────────────────────
|
|
|
|
public function trendingTags(int $limit = 10): Collection
|
|
{
|
|
$limit = min($limit, 10);
|
|
|
|
return Cache::remember("error_suggestions.tags.{$limit}", self::CACHE_TTL, function () use ($limit) {
|
|
return Tag::query()
|
|
->withCount('artworks')
|
|
->orderByDesc('artworks_count')
|
|
->limit($limit)
|
|
->get(['id', 'name', 'slug', 'artworks_count']);
|
|
});
|
|
}
|
|
|
|
// ── Trending creators (max 6) ─────────────────────────────────────────────
|
|
|
|
public function trendingCreators(int $limit = 6): Collection
|
|
{
|
|
$limit = min($limit, 6);
|
|
|
|
return Cache::remember("error_suggestions.creators.{$limit}", self::CACHE_TTL, function () use ($limit) {
|
|
return User::query()
|
|
->with('profile')
|
|
->withCount(['artworks' => fn ($q) => $q->public()->published()])
|
|
->having('artworks_count', '>', 0)
|
|
->orderByDesc('artworks_count')
|
|
->limit($limit)
|
|
->get(['users.id', 'users.name', 'users.username'])
|
|
->map(fn (User $u) => $this->creatorCard($u, $u->artworks_count));
|
|
});
|
|
}
|
|
|
|
// ── Recently joined creators (max 6) ─────────────────────────────────────
|
|
|
|
public function recentlyJoinedCreators(int $limit = 6): Collection
|
|
{
|
|
$limit = min($limit, 6);
|
|
|
|
return Cache::remember("error_suggestions.creators.recent.{$limit}", self::CACHE_TTL, function () use ($limit) {
|
|
return User::query()
|
|
->with('profile')
|
|
->withCount(['artworks' => fn ($q) => $q->public()->published()])
|
|
->having('artworks_count', '>', 0)
|
|
->orderByDesc('users.id')
|
|
->limit($limit)
|
|
->get(['users.id', 'users.name', 'users.username'])
|
|
->map(fn (User $u) => $this->creatorCard($u, $u->artworks_count));
|
|
});
|
|
}
|
|
|
|
// ── Latest blog posts (max 6) ─────────────────────────────────────────────
|
|
|
|
public function latestBlogPosts(int $limit = 6): Collection
|
|
{
|
|
$limit = min($limit, 6);
|
|
|
|
return Cache::remember("error_suggestions.blog.{$limit}", self::CACHE_TTL, function () use ($limit) {
|
|
return BlogPost::published()
|
|
->orderByDesc('published_at')
|
|
->limit($limit)
|
|
->get(['id', 'title', 'slug', 'excerpt', 'published_at'])
|
|
->map(fn ($p) => [
|
|
'id' => $p->id,
|
|
'title' => $p->title,
|
|
'excerpt' => Str::limit($p->excerpt ?? '', 100),
|
|
'url' => '/blog/' . $p->slug,
|
|
'published_at' => $p->published_at?->diffForHumans(),
|
|
]);
|
|
});
|
|
}
|
|
|
|
// ── Private helpers ───────────────────────────────────────────────────────
|
|
|
|
private function artworkCard(Artwork $a): array
|
|
{
|
|
$slug = Str::slug((string) ($a->slug ?: $a->title)) ?: (string) $a->id;
|
|
$md = ThumbnailPresenter::present($a, 'md');
|
|
|
|
return [
|
|
'id' => $a->id,
|
|
'title' => html_entity_decode((string) $a->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
|
'author' => html_entity_decode((string) ($a->user?->name ?: $a->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
|
'url' => route('art.show', ['id' => $a->id, 'slug' => $slug]),
|
|
'thumb' => $md['url'] ?? null,
|
|
];
|
|
}
|
|
|
|
private function creatorCard(User $u, int $artworksCount = 0): array
|
|
{
|
|
return [
|
|
'id' => $u->id,
|
|
'name' => $u->name ?: $u->username,
|
|
'username' => $u->username,
|
|
'url' => '/@' . $u->username,
|
|
'avatar_url' => \App\Support\AvatarUrl::forUser(
|
|
(int) $u->id,
|
|
optional($u->profile)->avatar_hash,
|
|
64
|
|
),
|
|
'artworks_count' => $artworksCount,
|
|
];
|
|
}
|
|
}
|