feat: Inertia profile settings page, Studio edit redesign, EGS, Nova UI components\n\n- Redesign /dashboard/profile as Inertia React page (Settings/ProfileEdit)\n with SettingsLayout sidebar, Nova UI components (TextInput, Textarea,\n Toggle, Select, RadioGroup, Modal, Button), avatar drag-and-drop,\n password change, and account deletion sections\n- Redesign Studio artwork edit page with two-column layout, Nova components,\n integrated TagPicker, and version history modal\n- Add shared MarkdownEditor component\n- Add Early-Stage Growth System (EGS): SpotlightEngine, FeedBlender,\n GridFiller, AdaptiveTimeWindow, ActivityLayer, admin panel\n- Fix upload category/tag persistence (V1+V2 paths)\n- Fix tag source enum, category tree display, binding resolution\n- Add settings.jsx Vite entry, settings.blade.php wrapper\n- Update ProfileController with JSON response support for API calls\n- Various route fixes (profile.edit, toolbar settings link)"
This commit is contained in:
116
app/Services/EarlyGrowth/SpotlightEngine.php
Normal file
116
app/Services/EarlyGrowth/SpotlightEngine.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\EarlyGrowth;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* SpotlightEngine
|
||||
*
|
||||
* Selects and rotates curated spotlight artworks for use in feed blending,
|
||||
* grid filling, and dedicated spotlight sections.
|
||||
*
|
||||
* Selection is date-seeded so the spotlight rotates daily without DB writes.
|
||||
* No artwork timestamps or engagement metrics are modified — this is purely
|
||||
* a read-and-present layer.
|
||||
*/
|
||||
final class SpotlightEngine implements SpotlightEngineInterface
|
||||
{
|
||||
/**
|
||||
* Return spotlight artworks for the current day.
|
||||
* Cached for `early_growth.cache_ttl.spotlight` seconds (default 1 hour).
|
||||
* Rotates daily via a date-seeded RAND() expression.
|
||||
*
|
||||
* Returns empty collection when SpotlightEngine is disabled.
|
||||
*/
|
||||
public function getSpotlight(int $limit = 6): Collection
|
||||
{
|
||||
if (! EarlyGrowth::spotlightEnabled()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$ttl = (int) config('early_growth.cache_ttl.spotlight', 3600);
|
||||
$cacheKey = 'egs.spotlight.' . now()->format('Y-m-d') . ".{$limit}";
|
||||
|
||||
return Cache::remember($cacheKey, $ttl, fn (): Collection => $this->selectSpotlight($limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return high-quality older artworks for feed blending ("curated" pool).
|
||||
* Excludes artworks newer than $olderThanDays to keep them out of the
|
||||
* "fresh" section yet available for blending.
|
||||
*
|
||||
* Cached per (limit, olderThanDays) tuple and rotated daily.
|
||||
*/
|
||||
public function getCurated(int $limit = 12, int $olderThanDays = 7): Collection
|
||||
{
|
||||
if (! EarlyGrowth::enabled()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$ttl = (int) config('early_growth.cache_ttl.spotlight', 3600);
|
||||
$cacheKey = 'egs.curated.' . now()->format('Y-m-d') . ".{$limit}.{$olderThanDays}";
|
||||
|
||||
return Cache::remember($cacheKey, $ttl, fn (): Collection => $this->selectCurated($limit, $olderThanDays));
|
||||
}
|
||||
|
||||
// ─── Private selection logic ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Select spotlight artworks.
|
||||
* Uses a date-based seed for deterministic daily rotation.
|
||||
* Fetches 3× the needed count and selects the top-ranked subset.
|
||||
*/
|
||||
private function selectSpotlight(int $limit): Collection
|
||||
{
|
||||
$seed = (int) now()->format('Ymd');
|
||||
|
||||
// Artworks published > 7 days ago with meaningful ranking score
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
||||
])
|
||||
->leftJoin('artwork_stats as _ast', '_ast.artwork_id', '=', 'artworks.id')
|
||||
->select('artworks.*')
|
||||
->where('artworks.published_at', '<=', now()->subDays(7))
|
||||
// Blend ranking quality with daily-seeded randomness so spotlight varies
|
||||
->orderByRaw("COALESCE(_ast.ranking_score, 0) * 0.6 + RAND({$seed}) * 0.4 DESC")
|
||||
->limit($limit * 3)
|
||||
->get()
|
||||
->sortByDesc(fn ($a) => optional($a->artworkStats)->ranking_score ?? 0)
|
||||
->take($limit)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select curated older artworks for feed blending.
|
||||
*/
|
||||
private function selectCurated(int $limit, int $olderThanDays): Collection
|
||||
{
|
||||
$seed = (int) now()->format('Ymd');
|
||||
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'categories:id,name,slug,content_type_id,parent_id,sort_order',
|
||||
])
|
||||
->leftJoin('artwork_stats as _ast2', '_ast2.artwork_id', '=', 'artworks.id')
|
||||
->select('artworks.*')
|
||||
->where('artworks.published_at', '<=', now()->subDays($olderThanDays))
|
||||
->orderByRaw("COALESCE(_ast2.ranking_score, 0) * 0.7 + RAND({$seed}) * 0.3 DESC")
|
||||
->limit($limit)
|
||||
->get()
|
||||
->values();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user