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