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,129 @@
<?php
declare(strict_types=1);
namespace App\Services\EarlyGrowth;
use App\Models\Artwork;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
/**
* GridFiller
*
* Ensures that browse / discover grids never appear half-empty.
* When real results fall below the configured minimum, it backfills
* with real trending artworks from the general pool.
*
* Rules (per spec):
* - Fill only the visible first page never mix page-number scopes.
* - Filler is always real content (no fake items).
* - The original total is not reduced (pagination links stay stable).
* - Content is not labelled as "filler" in the UI it is just valid content.
*/
final class GridFiller
{
/**
* Ensure a LengthAwarePaginator contains at least $minimum items on page 1.
* Returns the original paginator unchanged when:
* - EGS is disabled
* - Page is > 1
* - Real result count already meets the minimum
*/
public function fill(
LengthAwarePaginator $results,
int $minimum = 0,
int $page = 1,
): LengthAwarePaginator {
if (! EarlyGrowth::gridFillerEnabled() || $page > 1) {
return $results;
}
$minimum = $minimum > 0
? $minimum
: (int) config('early_growth.grid_min_results', 12);
$items = $results->getCollection();
$count = $items->count();
if ($count >= $minimum) {
return $results;
}
$needed = $minimum - $count;
$exclude = $items->pluck('id')->all();
$filler = $this->fetchTrendingFiller($needed + 6, $exclude)->take($needed);
$merged = $items
->concat($filler)
->unique('id')
->values();
return new LengthAwarePaginator(
$merged->all(),
max((int) $results->total(), $merged->count()), // never shrink reported total
$results->perPage(),
$page,
[
'path' => $results->path(),
'pageName' => $results->getPageName(),
]
);
}
/**
* Fill a plain Collection (for non-paginated grids like homepage sections).
*/
public function fillCollection(Collection $items, int $minimum = 0): Collection
{
if (! EarlyGrowth::gridFillerEnabled()) {
return $items;
}
$minimum = $minimum > 0
? $minimum
: (int) config('early_growth.grid_min_results', 12);
if ($items->count() >= $minimum) {
return $items;
}
$needed = $minimum - $items->count();
$exclude = $items->pluck('id')->all();
$filler = $this->fetchTrendingFiller($needed + 6, $exclude)->take($needed);
return $items->concat($filler)->unique('id')->values();
}
// ─── Private ─────────────────────────────────────────────────────────────
/**
* Pull high-ranking artworks as grid filler.
* Cache key includes an exclude-hash so different grids get distinct content.
*/
private function fetchTrendingFiller(int $limit, array $excludeIds): Collection
{
$ttl = (int) config('early_growth.cache_ttl.feed_blend', 300);
$excludeHash = md5(implode(',', array_slice(array_unique($excludeIds), 0, 50)));
$cacheKey = "egs.grid_filler.{$excludeHash}.{$limit}";
return Cache::remember($cacheKey, $ttl, function () use ($limit, $excludeIds): Collection {
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 _gf_stats', '_gf_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->when(! empty($excludeIds), fn ($q) => $q->whereNotIn('artworks.id', $excludeIds))
->orderByDesc('_gf_stats.ranking_score')
->limit($limit)
->get()
->values();
});
}
}