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:
124
app/Services/EarlyGrowth/FeedBlender.php
Normal file
124
app/Services/EarlyGrowth/FeedBlender.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\EarlyGrowth;
|
||||
|
||||
use App\Services\EarlyGrowth\SpotlightEngineInterface;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* FeedBlender
|
||||
*
|
||||
* Blends real fresh uploads with curated older content and spotlight picks
|
||||
* to make early-stage feeds feel alive and diverse without faking engagement.
|
||||
*
|
||||
* Rules:
|
||||
* - ONLY applied to page 1 — deeper pages use the real feed untouched.
|
||||
* - No fake artworks, timestamps, or metrics.
|
||||
* - Duplicates removed before merging.
|
||||
* - The original paginator's total / path / page-name are preserved so
|
||||
* pagination links and SEO canonical/prev/next remain correct.
|
||||
*
|
||||
* Mode blend ratios are defined in config/early_growth.php:
|
||||
* light → 60% fresh / 25% curated / 15% spotlight
|
||||
* aggressive → 30% fresh / 50% curated / 20% spotlight
|
||||
*/
|
||||
final class FeedBlender
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SpotlightEngineInterface $spotlight,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Blend a LengthAwarePaginator of fresh artworks with curated and spotlight content.
|
||||
*
|
||||
* @param LengthAwarePaginator $freshResults Original fresh-upload paginator
|
||||
* @param int $perPage Items per page
|
||||
* @param int $page Current page number
|
||||
* @return LengthAwarePaginator Blended paginator (page 1) or original (page > 1)
|
||||
*/
|
||||
public function blend(
|
||||
LengthAwarePaginator $freshResults,
|
||||
int $perPage = 24,
|
||||
int $page = 1,
|
||||
): LengthAwarePaginator {
|
||||
// Only blend on page 1; real pagination takes over for deeper pages
|
||||
if (! EarlyGrowth::enabled() || $page > 1) {
|
||||
return $freshResults;
|
||||
}
|
||||
|
||||
$ratios = EarlyGrowth::blendRatios();
|
||||
|
||||
if (($ratios['curated'] + $ratios['spotlight']) < 0.001) {
|
||||
// Mode is effectively "fresh only" — nothing to blend
|
||||
return $freshResults;
|
||||
}
|
||||
|
||||
$fresh = $freshResults->getCollection();
|
||||
$freshIds = $fresh->pluck('id')->toArray();
|
||||
|
||||
// Calculate absolute item counts from ratios
|
||||
[$freshCount, $curatedCount, $spotlightCount] = $this->allocateCounts($ratios, $perPage);
|
||||
|
||||
// Fetch sources — over-fetch to account for deduplication losses
|
||||
$curated = $this->spotlight
|
||||
->getCurated($curatedCount + 6)
|
||||
->filter(fn ($a) => ! in_array($a->id, $freshIds, true))
|
||||
->take($curatedCount)
|
||||
->values();
|
||||
|
||||
$curatedIds = $curated->pluck('id')->toArray();
|
||||
|
||||
$spotlightItems = $this->spotlight
|
||||
->getSpotlight($spotlightCount + 6)
|
||||
->filter(fn ($a) => ! in_array($a->id, $freshIds, true))
|
||||
->filter(fn ($a) => ! in_array($a->id, $curatedIds, true))
|
||||
->take($spotlightCount)
|
||||
->values();
|
||||
|
||||
// Compose blended page
|
||||
$blended = $fresh->take($freshCount)
|
||||
->concat($curated)
|
||||
->concat($spotlightItems)
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
// Pad back to $perPage with leftover fresh items if any source ran short
|
||||
if ($blended->count() < $perPage) {
|
||||
$usedIds = $blended->pluck('id')->toArray();
|
||||
$pad = $fresh
|
||||
->filter(fn ($a) => ! in_array($a->id, $usedIds, true))
|
||||
->take($perPage - $blended->count());
|
||||
$blended = $blended->concat($pad)->unique('id')->values();
|
||||
}
|
||||
|
||||
// Rebuild paginator preserving the real total so pagination links remain stable
|
||||
return new LengthAwarePaginator(
|
||||
$blended->take($perPage)->all(),
|
||||
$freshResults->total(), // ← real total, not blended count
|
||||
$perPage,
|
||||
$page,
|
||||
[
|
||||
'path' => $freshResults->path(),
|
||||
'pageName' => $freshResults->getPageName(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Private helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Distribute $perPage slots across fresh / curated / spotlight.
|
||||
* Returns [freshCount, curatedCount, spotlightCount].
|
||||
*/
|
||||
private function allocateCounts(array $ratios, int $perPage): array
|
||||
{
|
||||
$total = max(0.001, ($ratios['fresh'] ?? 0) + ($ratios['curated'] ?? 0) + ($ratios['spotlight'] ?? 0));
|
||||
$freshN = (int) round($perPage * ($ratios['fresh'] ?? 1.0) / $total);
|
||||
$curatedN = (int) round($perPage * ($ratios['curated'] ?? 0.0) / $total);
|
||||
$spotN = $perPage - $freshN - $curatedN;
|
||||
|
||||
return [max(0, $freshN), max(0, $curatedN), max(0, $spotN)];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user