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,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)];
}
}