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:
2026-03-03 20:57:43 +01:00
parent dc51d65440
commit b9c2d8597d
114 changed files with 8760 additions and 693 deletions

View 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();
}
}