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:
129
app/Services/EarlyGrowth/GridFiller.php
Normal file
129
app/Services/EarlyGrowth/GridFiller.php
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user