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,149 @@
<?php
declare(strict_types=1);
namespace App\Services\EarlyGrowth;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* ActivityLayer (§8 Optional)
*
* Surfaces real site-activity signals as human-readable summaries.
* All data is genuine no fabrication, no fake counts.
*
* Examples:
* "🔥 Trending this week: 24 artworks"
* "📈 Rising in Wallpapers"
* "🌟 5 new creators joined this month"
* "🎨 38 artworks published recently"
*
* Only active when EarlyGrowth::activityLayerEnabled() returns true.
*/
final class ActivityLayer
{
/**
* Return an array of activity signal strings for use in UI badges/widgets.
* Empty array when ActivityLayer is disabled.
*
* @return array<int, array{icon: string, text: string, type: string}>
*/
public function getSignals(): array
{
if (! EarlyGrowth::activityLayerEnabled()) {
return [];
}
$ttl = (int) config('early_growth.cache_ttl.activity', 1800);
return Cache::remember('egs.activity_signals', $ttl, fn (): array => $this->buildSignals());
}
// ─── Signal builders ─────────────────────────────────────────────────────
private function buildSignals(): array
{
$signals = [];
// §8: "X artworks published recently"
$recentCount = $this->recentArtworkCount(7);
if ($recentCount > 0) {
$signals[] = [
'icon' => '🎨',
'text' => "{$recentCount} artwork" . ($recentCount !== 1 ? 's' : '') . ' published this week',
'type' => 'uploads',
];
}
// §8: "X new creators joined this month"
$newCreators = $this->newCreatorsThisMonth();
if ($newCreators > 0) {
$signals[] = [
'icon' => '🌟',
'text' => "{$newCreators} new creator" . ($newCreators !== 1 ? 's' : '') . ' joined this month',
'type' => 'creators',
];
}
// §8: "Trending this week"
$trendingCount = $this->recentArtworkCount(7);
if ($trendingCount > 0) {
$signals[] = [
'icon' => '🔥',
'text' => 'Trending this week',
'type' => 'trending',
];
}
// §8: "Rising in Wallpapers" (first content type with recent uploads)
$risingType = $this->getRisingContentType();
if ($risingType !== null) {
$signals[] = [
'icon' => '📈',
'text' => "Rising in {$risingType}",
'type' => 'rising',
];
}
return array_values($signals);
}
/**
* Count approved public artworks published in the last N days.
*/
private function recentArtworkCount(int $days): int
{
try {
return Artwork::query()
->where('is_public', true)
->where('is_approved', true)
->whereNull('deleted_at')
->where('published_at', '>=', now()->subDays($days))
->count();
} catch (\Throwable) {
return 0;
}
}
/**
* Count users who registered (email_verified_at set) this calendar month.
*/
private function newCreatorsThisMonth(): int
{
try {
return User::query()
->whereNotNull('email_verified_at')
->where('email_verified_at', '>=', now()->startOfMonth())
->count();
} catch (\Throwable) {
return 0;
}
}
/**
* Returns the name of the content type with the most uploads in the last 30 days,
* or null if the content_types table isn't available.
*/
private function getRisingContentType(): ?string
{
try {
$row = DB::table('artworks')
->join('content_types', 'content_types.id', '=', 'artworks.content_type_id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNull('artworks.deleted_at')
->where('artworks.published_at', '>=', now()->subDays(30))
->selectRaw('content_types.name, COUNT(*) as cnt')
->groupBy('content_types.id', 'content_types.name')
->orderByDesc('cnt')
->first();
return $row ? (string) $row->name : null;
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Services\EarlyGrowth;
use App\Models\Artwork;
use Illuminate\Support\Facades\Cache;
/**
* AdaptiveTimeWindow
*
* Dynamically widens the look-back window used by trending / rising feeds
* when recent upload volume is below configured thresholds.
*
* This only affects RANKING QUERIES it never modifies artwork timestamps,
* canonical URLs, or any stored data.
*
* Behaviour:
* uploads/day narrow_threshold normal window (7 d)
* uploads/day wide_threshold medium window (30 d)
* uploads/day < wide_threshold wide window (90 d)
*
* All thresholds and window sizes are configurable in config/early_growth.php.
*/
final class AdaptiveTimeWindow
{
/**
* Return the number of look-back days to use for trending / rising queries.
*
* @param int $defaultDays Returned as-is when EGS is disabled.
*/
public function getTrendingWindowDays(int $defaultDays = 30): int
{
if (! EarlyGrowth::adaptiveWindowEnabled()) {
return $defaultDays;
}
$uploadsPerDay = $this->getUploadsPerDay();
$narrowThreshold = (int) config('early_growth.thresholds.uploads_per_day_narrow', 10);
$wideThreshold = (int) config('early_growth.thresholds.uploads_per_day_wide', 3);
$narrowDays = (int) config('early_growth.thresholds.window_narrow_days', 7);
$mediumDays = (int) config('early_growth.thresholds.window_medium_days', 30);
$wideDays = (int) config('early_growth.thresholds.window_wide_days', 90);
if ($uploadsPerDay >= $narrowThreshold) {
return $narrowDays; // Healthy activity → normal 7-day window
}
if ($uploadsPerDay >= $wideThreshold) {
return $mediumDays; // Moderate activity → expand to 30 days
}
return $wideDays; // Low activity → expand to 90 days
}
/**
* Rolling 7-day average of approved public uploads per day.
* Cached for `early_growth.cache_ttl.time_window` seconds.
*/
public function getUploadsPerDay(): float
{
$ttl = (int) config('early_growth.cache_ttl.time_window', 600);
return Cache::remember('egs.uploads_per_day', $ttl, function (): float {
$count = Artwork::query()
->where('is_public', true)
->where('is_approved', true)
->whereNull('deleted_at')
->whereNotNull('published_at')
->where('published_at', '>=', now()->subDays(7))
->count();
return round($count / 7, 2);
});
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Services\EarlyGrowth;
use App\Models\Artwork;
use Illuminate\Support\Facades\Cache;
/**
* EarlyGrowth
*
* Central service for the Early-Stage Growth System.
* All other EGS modules consult this class for feature-flag status.
*
* Toggle via .env:
* NOVA_EARLY_GROWTH_ENABLED=true
* NOVA_EARLY_GROWTH_MODE=light # off | light | aggressive
*
* Set NOVA_EARLY_GROWTH_ENABLED=false to instantly revert to
* normal production behaviour across every integration point.
*/
final class EarlyGrowth
{
// ─── Feature-flag helpers ─────────────────────────────────────────────────
/**
* Is the entire Early Growth System active?
* Checks master enabled flag AND that mode is not 'off'.
*/
public static function enabled(): bool
{
if (! (bool) config('early_growth.enabled', false)) {
return false;
}
// Auto-disable check (optional)
if ((bool) config('early_growth.auto_disable.enabled', false) && self::shouldAutoDisable()) {
return false;
}
return self::mode() !== 'off';
}
/**
* Current operating mode: off | light | aggressive
*/
public static function mode(): string
{
$mode = (string) config('early_growth.mode', 'off');
return in_array($mode, ['off', 'light', 'aggressive'], true) ? $mode : 'off';
}
/** Is the AdaptiveTimeWindow module active? */
public static function adaptiveWindowEnabled(): bool
{
return self::enabled() && (bool) config('early_growth.adaptive_time_window', true);
}
/** Is the GridFiller module active? */
public static function gridFillerEnabled(): bool
{
return self::enabled() && (bool) config('early_growth.grid_filler', true);
}
/** Is the SpotlightEngine module active? */
public static function spotlightEnabled(): bool
{
return self::enabled() && (bool) config('early_growth.spotlight', true);
}
/** Is the optional ActivityLayer module active? */
public static function activityLayerEnabled(): bool
{
return self::enabled() && (bool) config('early_growth.activity_layer', false);
}
/**
* Blend ratios for the current mode.
* Returns proportions for fresh / curated / spotlight slices.
*/
public static function blendRatios(): array
{
$mode = self::mode();
return config("early_growth.blend_ratios.{$mode}", [
'fresh' => 1.0,
'curated' => 0.0,
'spotlight' => 0.0,
]);
}
// ─── Auto-disable logic ───────────────────────────────────────────────────
/**
* Check whether upload volume or active-user count has crossed the
* configured threshold for organic scale, and the system should self-disable.
* Result is cached for 10 minutes to avoid constant DB polling.
*/
private static function shouldAutoDisable(): bool
{
return (bool) Cache::remember('egs.auto_disable_check', 600, function (): bool {
$uploadsThreshold = (int) config('early_growth.auto_disable.uploads_per_day', 50);
$usersThreshold = (int) config('early_growth.auto_disable.active_users', 500);
// Average daily uploads over the last 7 days
$recentUploads = Artwork::query()
->where('is_public', true)
->where('is_approved', true)
->whereNull('deleted_at')
->where('published_at', '>=', now()->subDays(7))
->count();
$uploadsPerDay = $recentUploads / 7;
if ($uploadsPerDay >= $uploadsThreshold) {
return true;
}
// Active users: verified accounts who uploaded in last 30 days
$activeCreators = Artwork::query()
->where('is_public', true)
->where('is_approved', true)
->where('published_at', '>=', now()->subDays(30))
->distinct('user_id')
->count('user_id');
return $activeCreators >= $usersThreshold;
});
}
// ─── Status summary ──────────────────────────────────────────────────────
/**
* Return a summary array suitable for admin panels / logging.
*/
public static function status(): array
{
return [
'enabled' => self::enabled(),
'mode' => self::mode(),
'adaptive_window' => self::adaptiveWindowEnabled(),
'grid_filler' => self::gridFillerEnabled(),
'spotlight' => self::spotlightEnabled(),
'activity_layer' => self::activityLayerEnabled(),
];
}
}

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

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

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

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Services\EarlyGrowth;
use Illuminate\Support\Collection;
/**
* Contract for spotlight / curated content selection.
* Allows test doubles and alternative implementations.
*/
interface SpotlightEngineInterface
{
public function getSpotlight(int $limit = 6): Collection;
public function getCurated(int $limit = 12, int $olderThanDays = 7): Collection;
}