diff --git a/.env.example b/.env.example index 60cbfdac..75415212 100644 --- a/.env.example +++ b/.env.example @@ -210,6 +210,25 @@ YOLO_HTTP_RETRIES=1 YOLO_HTTP_RETRY_DELAY_MS=200 YOLO_PHOTOGRAPHY_ONLY=true +# Academy feature flags +SKINBASE_ACADEMY_ENABLED=true +SKINBASE_ACADEMY_PAYMENTS_ENABLED=false +ACADEMY_BILLING_ENABLED=false +ACADEMY_STRIPE_SUBSCRIPTION_NAME=academy + +# Stripe / Cashier +STRIPE_KEY=pk_test_xxx +STRIPE_SECRET=sk_test_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx +CASHIER_CURRENCY=eur +CASHIER_CURRENCY_LOCALE=sl_SI + +# Academy billing price IDs +ACADEMY_CREATOR_MONTHLY_PRICE_ID=price_xxx +ACADEMY_PRO_MONTHLY_PRICE_ID=price_xxx + +# Stripe expects real price object IDs that start with price_, not product IDs like prod_... + # ----------------------------------------------------------------------------- # Production examples (uncomment and adjust) # ----------------------------------------------------------------------------- diff --git a/app/Console/Commands/AcademyAnalyticsHealthCommand.php b/app/Console/Commands/AcademyAnalyticsHealthCommand.php new file mode 100644 index 00000000..56cc38db --- /dev/null +++ b/app/Console/Commands/AcademyAnalyticsHealthCommand.php @@ -0,0 +1,183 @@ +buildReport(); + + if ((bool) $this->option('json')) { + $this->line(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: '{}'); + + return self::SUCCESS; + } + + $this->line('Academy Analytics Health Check'); + $this->line('=============================='); + $this->newLine(); + $this->line(sprintf('Events last 24h: %d', $report['events_last_24h'])); + $this->line(sprintf('Events last 7d: %d', $report['events_last_7d'])); + $this->line(sprintf('Latest event: %s', $report['latest_event_at'] ?? 'none')); + $this->line(sprintf('Latest rollup date: %s', $report['latest_rollup_date'] ?? 'none')); + $this->line(sprintf('Search logs: %d', $report['search_logs'])); + $this->line(sprintf('Search clicks: %d', $report['search_clicks'])); + $this->line(sprintf('Likes: %d', $report['likes'])); + $this->line(sprintf('Saves: %d', $report['saves'])); + $this->line(sprintf('Progress records: %d', $report['progress_records'])); + $this->line(sprintf('Prompt copies: %d', $report['prompt_copies'])); + $this->line(sprintf('Upgrade clicks: %d', $report['upgrade_clicks'])); + $this->line(sprintf('Human events: %d', $report['human_events'])); + $this->line(sprintf('Bot/admin events: %d', $report['bot_admin_events'])); + $this->line(sprintf('Recent daily metric rows: %d', $report['recent_daily_metric_rows'])); + $this->line(sprintf('Raw IP storage detected: %s', $report['raw_ip_storage_detected'] ? 'yes' : 'no')); + $this->line(sprintf('Events older than retention: %d', $report['events_older_than_retention'])); + $this->newLine(); + + foreach ($report['warnings'] as $warning) { + $this->warn(sprintf('WARNING: %s', $warning)); + } + + $this->info(sprintf('Status: %s', $report['status'])); + + return self::SUCCESS; + } + + /** + * @return array + */ + private function buildReport(): array + { + $now = now(); + $last24Hours = $now->copy()->subDay(); + $last7Days = $now->copy()->subDays(7); + $retentionCutoff = $now->copy()->subDays(self::RETENTION_DAYS); + $warnings = []; + $rawIpStorageDetected = $this->rawIpStorageDetected(); + $eventsTableExists = Schema::hasTable('academy_events'); + $metricsTableExists = Schema::hasTable('academy_content_metrics_daily'); + $searchLogsTableExists = Schema::hasTable('academy_search_logs'); + $likesTableExists = Schema::hasTable('academy_likes'); + $savesTableExists = Schema::hasTable('academy_saves'); + $progressTableExists = Schema::hasTable('academy_user_progress'); + + $latestEvent = $eventsTableExists ? AcademyEvent::query()->latest('occurred_at')->value('occurred_at') : null; + $latestRollup = $metricsTableExists ? AcademyContentMetricDaily::query()->latest('date')->value('date') : null; + $searchLogCount = $searchLogsTableExists ? AcademySearchLog::query()->count() : 0; + $searchClickCount = $searchLogsTableExists ? AcademySearchLog::query()->whereNotNull('clicked_content_id')->count() : 0; + $eventsOlderThanRetention = $eventsTableExists ? AcademyEvent::query()->where('occurred_at', '<', $retentionCutoff)->count() : 0; + $recentDailyMetricRows = $metricsTableExists + ? AcademyContentMetricDaily::query()->whereBetween('date', [$now->copy()->subDays(6)->toDateString(), $now->toDateString()])->count() + : 0; + + $report = [ + 'events_last_24h' => $eventsTableExists ? AcademyEvent::query()->where('occurred_at', '>=', $last24Hours)->count() : 0, + 'events_last_7d' => $eventsTableExists ? AcademyEvent::query()->where('occurred_at', '>=', $last7Days)->count() : 0, + 'latest_event_at' => $latestEvent ? Carbon::parse((string) $latestEvent)->toDateTimeString() : null, + 'latest_rollup_date' => $latestRollup ? Carbon::parse((string) $latestRollup)->toDateString() : null, + 'search_logs' => $searchLogCount, + 'search_clicks' => $searchClickCount, + 'likes' => $likesTableExists ? AcademyLike::query()->count() : 0, + 'saves' => $savesTableExists ? AcademySave::query()->count() : 0, + 'progress_records' => $progressTableExists ? AcademyUserProgress::query()->count() : 0, + 'prompt_copies' => $eventsTableExists ? AcademyEvent::query()->where('event_type', AcademyAnalyticsEventType::PROMPT_COPY)->count() : 0, + 'upgrade_clicks' => $eventsTableExists ? AcademyEvent::query()->where('event_type', AcademyAnalyticsEventType::UPGRADE_CLICK)->count() : 0, + 'human_events' => $eventsTableExists ? AcademyEvent::query()->where('is_bot', false)->where('is_admin', false)->where('is_suspicious', false)->count() : 0, + 'bot_admin_events' => $eventsTableExists ? AcademyEvent::query()->where(function ($query): void { + $query->where('is_bot', true)->orWhere('is_admin', true)->orWhere('is_suspicious', true); + })->count() : 0, + 'raw_ip_storage_detected' => $rawIpStorageDetected, + 'events_older_than_retention' => $eventsOlderThanRetention, + 'recent_daily_metric_rows' => $recentDailyMetricRows, + 'retention_days' => self::RETENTION_DAYS, + 'tables_present' => [ + 'academy_events' => $eventsTableExists, + 'academy_content_metrics_daily' => $metricsTableExists, + 'academy_search_logs' => $searchLogsTableExists, + 'academy_likes' => $likesTableExists, + 'academy_saves' => $savesTableExists, + 'academy_user_progress' => $progressTableExists, + ], + 'warnings' => [], + 'status' => 'OK', + ]; + + foreach ($report['tables_present'] as $table => $present) { + if (! $present) { + $warnings[] = sprintf('Analytics table %s is missing.', $table); + } + } + + if ($report['events_last_24h'] === 0) { + $warnings[] = 'No events received in last 24 hours.'; + } + + if ($report['events_last_7d'] === 0) { + $warnings[] = 'No events received in last 7 days.'; + } + + if ($report['latest_rollup_date'] === null) { + $warnings[] = 'No rollup rows exist yet.'; + } elseif ($report['latest_rollup_date'] !== $now->toDateString()) { + $warnings[] = 'Rollup has not run for today.'; + } + + if ($searchLogCount > 0 && $searchClickCount === 0) { + $warnings[] = 'Search clicks are zero although search logs exist.'; + } + + if ($eventsOlderThanRetention > 0) { + $warnings[] = 'Raw events older than configured retention period exist.'; + } + + if ($recentDailyMetricRows === 0) { + $warnings[] = 'No daily metrics exist for recent days.'; + } + + if ($rawIpStorageDetected) { + $warnings[] = 'Raw IP storage indicators were found in Academy analytics tables.'; + } + + $report['warnings'] = $warnings; + $report['status'] = $warnings === [] ? 'OK' : 'WARNING'; + + return $report; + } + + private function rawIpStorageDetected(): bool + { + foreach (['academy_events', 'academy_search_logs', 'academy_content_metrics_daily', 'academy_likes', 'academy_saves', 'academy_user_progress'] as $table) { + if (! Schema::hasTable($table)) { + continue; + } + + foreach (['ip', 'ip_address', 'visitor_ip', 'raw_ip', 'remote_addr'] as $column) { + if (Schema::hasColumn($table, $column)) { + return true; + } + } + } + + return false; + } +} diff --git a/app/Console/Commands/AcademyAnalyticsPruneEventsCommand.php b/app/Console/Commands/AcademyAnalyticsPruneEventsCommand.php new file mode 100644 index 00000000..4391506a --- /dev/null +++ b/app/Console/Commands/AcademyAnalyticsPruneEventsCommand.php @@ -0,0 +1,27 @@ +option('days')); + $deleted = AcademyEvent::query() + ->where('occurred_at', '<', now()->subDays($days)->startOfDay()) + ->delete(); + + $this->info(sprintf('Pruned %d Academy analytics event(s).', $deleted)); + + return self::SUCCESS; + } +} \ No newline at end of file diff --git a/app/Console/Commands/AcademyAnalyticsRecalculatePopularityCommand.php b/app/Console/Commands/AcademyAnalyticsRecalculatePopularityCommand.php new file mode 100644 index 00000000..290dc2bd --- /dev/null +++ b/app/Console/Commands/AcademyAnalyticsRecalculatePopularityCommand.php @@ -0,0 +1,41 @@ +option('days')); + + AcademyContentMetricDaily::query() + ->where('date', '>=', now()->subDays($days - 1)->toDateString()) + ->chunkById(500, function ($rows): void { + foreach ($rows as $row) { + $row->forceFill([ + 'popularity_score' => $this->popularity->calculatePopularityScore($row->toArray()), + 'conversion_score' => $this->popularity->calculateConversionScore($row->toArray()), + ])->save(); + } + }); + + $this->info(sprintf('Recalculated Academy popularity for the last %d day(s).', $days)); + + return self::SUCCESS; + } +} \ No newline at end of file diff --git a/app/Console/Commands/AcademyAnalyticsRollupCommand.php b/app/Console/Commands/AcademyAnalyticsRollupCommand.php new file mode 100644 index 00000000..28bebf0e --- /dev/null +++ b/app/Console/Commands/AcademyAnalyticsRollupCommand.php @@ -0,0 +1,258 @@ +resolveRange(); + + foreach (CarbonPeriod::create($from, $to) as $date) { + $this->rollupDate(Carbon::parse($date)); + $this->line(sprintf('Rolled up Academy analytics for %s.', Carbon::parse($date)->toDateString())); + } + + return self::SUCCESS; + } + + private function rollupDate(Carbon $date): void + { + $start = $date->copy()->startOfDay(); + $end = $date->copy()->endOfDay(); + $metrics = []; + $uniqueVisitors = []; + $engagedDurations = []; + + AcademyEvent::query() + ->whereBetween('occurred_at', [$start, $end]) + ->orderBy('id') + ->chunkById(1000, function ($events) use (&$metrics, &$uniqueVisitors, &$engagedDurations): void { + foreach ($events as $event) { + if ($event->is_bot || $event->is_admin || $event->is_suspicious) { + continue; + } + + $key = $this->metricKey((string) ($event->content_type ?? ''), $event->content_id ? (int) $event->content_id : null); + $this->ensureMetric($metrics, (string) ($event->content_type ?? ''), $event->content_id ? (int) $event->content_id : null, $key); + + $visitorKey = $event->user_id ? sprintf('user:%d', (int) $event->user_id) : trim((string) ($event->visitor_id ?? '')); + if ($visitorKey !== '') { + $uniqueVisitors[$key][$visitorKey] = true; + } + + $eventType = (string) $event->event_type; + if (in_array($eventType, ['academy_page_view', 'academy_content_view', 'academy_lesson_view', 'academy_course_view', 'academy_prompt_pack_view', 'academy_challenge_view'], true)) { + $metrics[$key]['views']++; + if ($event->is_logged_in) { + $metrics[$key]['user_views']++; + } else { + $metrics[$key]['guest_views']++; + } + if ($event->is_subscriber) { + $metrics[$key]['subscriber_views']++; + } + } + + if ($eventType === 'academy_engaged_view') { + $metrics[$key]['engaged_views']++; + $engagedDurations[$key][] = max(0, (int) ($event->metadata['engaged_seconds'] ?? 15)); + } + + if ($eventType === 'academy_scroll_50') { + $metrics[$key]['scroll_50']++; + } + if ($eventType === 'academy_scroll_75') { + $metrics[$key]['scroll_75']++; + } + if ($eventType === 'academy_scroll_100') { + $metrics[$key]['scroll_100']++; + } + if ($eventType === 'academy_prompt_copy') { + $metrics[$key]['prompt_copies']++; + } + if ($eventType === 'academy_prompt_negative_copy') { + $metrics[$key]['negative_prompt_copies']++; + } + if (in_array($eventType, ['academy_lesson_started', 'academy_course_started', 'academy_challenge_started'], true)) { + $metrics[$key]['starts']++; + } + if (in_array($eventType, ['academy_lesson_completed', 'academy_course_completed', 'academy_challenge_submitted'], true)) { + $metrics[$key]['completions']++; + } + if ($eventType === 'academy_upgrade_click') { + $metrics[$key]['upgrade_clicks']++; + } + if ($eventType === 'academy_premium_preview_view') { + $metrics[$key]['premium_preview_views']++; + } + if ($eventType === 'academy_search_result_click') { + $metrics[$key]['search_clicks']++; + + $searchKey = $this->metricKey(AcademyAnalyticsContentType::SEARCH, null); + $this->ensureMetric($metrics, AcademyAnalyticsContentType::SEARCH, null, $searchKey); + $metrics[$searchKey]['search_clicks']++; + } + } + }); + + foreach (AcademyLike::query()->whereBetween('created_at', [$start, $end])->get() as $like) { + $key = $this->metricKey((string) $like->content_type, (int) $like->content_id); + $this->ensureMetric($metrics, (string) $like->content_type, (int) $like->content_id, $key); + $metrics[$key]['likes']++; + } + + foreach (AcademySave::query()->whereBetween('created_at', [$start, $end])->get() as $save) { + $key = $this->metricKey((string) $save->content_type, (int) $save->content_id); + $this->ensureMetric($metrics, (string) $save->content_type, (int) $save->content_id, $key); + $metrics[$key]['saves']++; + } + + foreach (AcademySearchLog::query()->whereBetween('created_at', [$start, $end])->get() as $searchLog) { + $key = $this->metricKey(AcademyAnalyticsContentType::SEARCH, null); + $this->ensureMetric($metrics, AcademyAnalyticsContentType::SEARCH, null, $key); + $metrics[$key]['search_impressions']++; + if ((int) $searchLog->results_count === 0) { + $metrics[$key]['bounce_count']++; + } + + $visitorKey = $searchLog->user_id ? sprintf('user:%d', (int) $searchLog->user_id) : trim((string) ($searchLog->visitor_id ?? '')); + if ($visitorKey !== '') { + $uniqueVisitors[$key][$visitorKey] = true; + } + } + + foreach ($metrics as $key => $metric) { + $metric['unique_visitors'] = isset($uniqueVisitors[$key]) ? count($uniqueVisitors[$key]) : 0; + $metric['avg_engaged_seconds'] = isset($engagedDurations[$key]) && $engagedDurations[$key] !== [] + ? (int) round(array_sum($engagedDurations[$key]) / count($engagedDurations[$key])) + : null; + $metric['bounce_count'] = max((int) ($metric['bounce_count'] ?? 0), max(0, (int) $metric['views'] - (int) $metric['engaged_views'])); + $metric['popularity_score'] = $this->popularity->calculatePopularityScore($metric); + $metric['conversion_score'] = $this->popularity->calculateConversionScore($metric); + + AcademyContentMetricDaily::query()->upsert([ + array_merge($metric, [ + 'date' => $date->copy()->startOfDay(), + 'created_at' => now(), + 'updated_at' => now(), + ]), + ], ['date', 'content_type', 'content_id'], [ + 'views', + 'unique_visitors', + 'guest_views', + 'user_views', + 'subscriber_views', + 'engaged_views', + 'scroll_50', + 'scroll_75', + 'scroll_100', + 'likes', + 'saves', + 'prompt_copies', + 'negative_prompt_copies', + 'starts', + 'completions', + 'upgrade_clicks', + 'premium_preview_views', + 'search_impressions', + 'search_clicks', + 'bounce_count', + 'avg_engaged_seconds', + 'popularity_score', + 'conversion_score', + 'updated_at', + ]); + } + } + + /** + * @param array> $metrics + */ + private function ensureMetric(array &$metrics, string $contentType, ?int $contentId, string $key): void + { + if (isset($metrics[$key])) { + return; + } + + $metrics[$key] = [ + 'content_type' => $contentType, + 'content_id' => $contentId, + 'views' => 0, + 'unique_visitors' => 0, + 'guest_views' => 0, + 'user_views' => 0, + 'subscriber_views' => 0, + 'engaged_views' => 0, + 'scroll_50' => 0, + 'scroll_75' => 0, + 'scroll_100' => 0, + 'likes' => 0, + 'saves' => 0, + 'prompt_copies' => 0, + 'negative_prompt_copies' => 0, + 'starts' => 0, + 'completions' => 0, + 'upgrade_clicks' => 0, + 'premium_preview_views' => 0, + 'search_impressions' => 0, + 'search_clicks' => 0, + 'bounce_count' => 0, + 'avg_engaged_seconds' => null, + 'popularity_score' => 0, + 'conversion_score' => 0, + ]; + } + + private function metricKey(string $contentType, ?int $contentId): string + { + return sprintf('%s:%s', $contentType, $contentId ?? 'none'); + } + + /** + * @return array{0: Carbon, 1: Carbon} + */ + private function resolveRange(): array + { + $date = $this->option('date'); + $from = $this->option('from'); + $to = $this->option('to'); + + if (is_string($date) && trim($date) !== '') { + $resolved = Carbon::parse($date)->startOfDay(); + + return [$resolved, $resolved->copy()]; + } + + $resolvedFrom = is_string($from) && trim($from) !== '' + ? Carbon::parse($from)->startOfDay() + : now()->subDay()->startOfDay(); + $resolvedTo = is_string($to) && trim($to) !== '' + ? Carbon::parse($to)->startOfDay() + : $resolvedFrom->copy(); + + return [$resolvedFrom, $resolvedTo]; + } +} \ No newline at end of file diff --git a/app/Console/Commands/AcademyBillingHealthCommand.php b/app/Console/Commands/AcademyBillingHealthCommand.php new file mode 100644 index 00000000..4d9351eb --- /dev/null +++ b/app/Console/Commands/AcademyBillingHealthCommand.php @@ -0,0 +1,288 @@ +buildReport(); + + if ((bool) $this->option('json')) { + $this->line((string) json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return $this->exitCodeFor($report); + } + + $this->line('Academy Billing Health Check'); + $this->line('============================'); + $this->newLine(); + $this->line(sprintf('Environment: %s', $report['environment'])); + $this->line(sprintf('App URL: %s', $report['app_url'] ?? 'unset')); + $this->line(sprintf('Academy enabled: %s', $report['academy_enabled'] ? 'yes' : 'no')); + $this->line(sprintf('Academy billing enabled: %s', $report['academy_billing_enabled'] ? 'yes' : 'no')); + $this->line(sprintf('Subscription name: %s', $report['subscription_name'])); + $this->line(sprintf('Cashier path: %s', $report['cashier_path'])); + $this->line(sprintf('Cashier webhook route: %s', $report['routes']['cashier_webhook']['present'] ? ($report['routes']['cashier_webhook']['url'] ?? 'present') : 'missing')); + $this->line(sprintf('Academy pricing route: %s', $report['routes']['academy_pricing']['present'] ? ($report['routes']['academy_pricing']['url'] ?? 'present') : 'missing')); + $this->line(sprintf('Academy billing account route: %s', $report['routes']['academy_billing_account']['present'] ? ($report['routes']['academy_billing_account']['url'] ?? 'present') : 'missing')); + $this->line(sprintf('Stripe key configured: %s', $report['stripe']['publishable_key_configured'] ? 'yes' : 'no')); + $this->line(sprintf('Stripe secret configured: %s', $report['stripe']['secret_key_configured'] ? 'yes' : 'no')); + $this->line(sprintf('Webhook secret configured: %s', $report['stripe']['webhook_secret_configured'] ? 'yes' : 'no')); + $this->line(sprintf('Cashier currency: %s', $report['stripe']['currency'] ?: 'unset')); + $this->line(sprintf('Cashier locale: %s', $report['stripe']['currency_locale'] ?: 'unset')); + $this->line(sprintf('Configured plans: %d', $report['configured_plan_count'])); + $this->line(sprintf('Plans missing Stripe price IDs: %d', count($report['missing_plan_keys']))); + $this->line(sprintf('Billing tables present: %s', $report['tables']['subscriptions'] && $report['tables']['subscription_items'] && $report['tables']['academy_billing_events'] ? 'yes' : 'no')); + $this->line(sprintf('User billing columns present: %s', $report['users_billing_columns_present'] ? 'yes' : 'no')); + $this->newLine(); + + foreach ($report['blockers'] as $blocker) { + $this->error(sprintf('BLOCKER: %s', $blocker)); + } + + foreach ($report['warnings'] as $warning) { + $this->warn(sprintf('WARNING: %s', $warning)); + } + + if ($report['plan_summaries'] !== []) { + $this->newLine(); + $this->line('Plans'); + $this->line('-----'); + + foreach ($report['plan_summaries'] as $plan) { + $this->line(sprintf( + '%s: tier=%s interval=%s price_id=%s', + $plan['key'], + $plan['tier'], + $plan['interval'], + $plan['configured'] ? 'configured' : 'missing' + )); + } + } + + $this->newLine(); + $this->info(sprintf('Status: %s', $report['status'])); + + return $this->exitCodeFor($report); + } + + /** + * @return array + */ + private function buildReport(): array + { + $stripeKey = (string) config('cashier.key', ''); + $stripeSecret = (string) config('cashier.secret', env('STRIPE_SECRET', '')); + $webhookSecret = (string) config('cashier.webhook.secret', env('STRIPE_WEBHOOK_SECRET', '')); + $currency = trim((string) config('cashier.currency', env('CASHIER_CURRENCY', ''))); + $currencyLocale = trim((string) config('cashier.currency_locale', env('CASHIER_CURRENCY_LOCALE', ''))); + $academyEnabled = (bool) config('academy.enabled', true); + $billingEnabled = $this->plans->enabled(); + $missingPlanKeys = $this->plans->missingPriceIds(); + $routes = [ + 'cashier_webhook' => $this->routeStatus('cashier.webhook'), + 'academy_pricing' => $this->routeStatus('academy.pricing'), + 'academy_billing_account' => $this->routeStatus('academy.billing.account'), + 'academy_billing_portal' => $this->routeStatus('academy.billing.portal'), + 'admin_academy_billing' => $this->routeStatus('admin.academy.billing'), + ]; + $tables = [ + 'users' => Schema::hasTable('users'), + 'subscriptions' => Schema::hasTable('subscriptions'), + 'subscription_items' => Schema::hasTable('subscription_items'), + 'academy_billing_events' => Schema::hasTable('academy_billing_events'), + ]; + $userBillingColumns = [ + 'stripe_id' => $tables['users'] && Schema::hasColumn('users', 'stripe_id'), + 'pm_type' => $tables['users'] && Schema::hasColumn('users', 'pm_type'), + 'pm_last_four' => $tables['users'] && Schema::hasColumn('users', 'pm_last_four'), + 'trial_ends_at' => $tables['users'] && Schema::hasColumn('users', 'trial_ends_at'), + ]; + $planSummaries = collect(array_keys($this->plans->plans())) + ->map(function (string $key): array { + $plan = $this->plans->plan($key); + + return [ + 'key' => $key, + 'tier' => (string) ($plan['tier'] ?? 'free'), + 'interval' => (string) ($plan['interval'] ?? 'monthly'), + 'configured' => (bool) ($plan['configured'] ?? false), + ]; + }) + ->values() + ->all(); + + $blockers = []; + $warnings = []; + + if (! $academyEnabled) { + $warnings[] = 'SKINBASE_ACADEMY_ENABLED is disabled, so billing cannot be reached by users.'; + } + + if (! $billingEnabled) { + $warnings[] = 'ACADEMY_BILLING_ENABLED is disabled. Checkout routes will stay unavailable until rollout is enabled.'; + } + + if (! $this->isConfiguredSecret($stripeKey, 'pk_')) { + $blockers[] = 'STRIPE_KEY is missing or still using a placeholder value.'; + } + + if (! $this->isConfiguredSecret($stripeSecret, 'sk_')) { + $blockers[] = 'STRIPE_SECRET is missing or still using a placeholder value.'; + } + + if (! $this->isConfiguredSecret($webhookSecret, 'whsec_')) { + $blockers[] = 'STRIPE_WEBHOOK_SECRET is missing or still using a placeholder value.'; + } + + if ($currency === '') { + $blockers[] = 'CASHIER_CURRENCY is not configured.'; + } + + if ($currencyLocale === '') { + $warnings[] = 'CASHIER_CURRENCY_LOCALE is not configured.'; + } + + if ($missingPlanKeys !== []) { + $blockers[] = 'Stripe price IDs are missing for: '.implode(', ', $missingPlanKeys).'.'; + } + + if (! $routes['cashier_webhook']['present']) { + $blockers[] = 'Cashier webhook route is missing; Stripe cannot sync subscriptions.'; + } + + if (! $routes['academy_pricing']['present']) { + $blockers[] = 'Academy pricing route is missing.'; + } + + if (! $routes['academy_billing_account']['present']) { + $blockers[] = 'Academy billing account route is missing.'; + } + + foreach ($tables as $table => $present) { + if (! $present) { + $blockers[] = sprintf('Required billing table %s is missing.', $table); + } + } + + foreach ($userBillingColumns as $column => $present) { + if (! $present) { + $blockers[] = sprintf('Required users.%s billing column is missing.', $column); + } + } + + if (! $routes['admin_academy_billing']['present']) { + $warnings[] = 'Moderation Academy billing overview route is missing.'; + } + + if (Arr::where($planSummaries, fn (array $plan): bool => $plan['configured'] === false) === []) { + $warnings[] = 'All configured Academy plans have Stripe price IDs. Verify they are live-mode IDs before production rollout.'; + } + + $invalidPlanKeys = collect(array_keys($this->plans->plans())) + ->filter(function (string $key): bool { + $plan = $this->plans->plan($key); + + return $plan !== null && ($plan['configured'] ?? false) && ! ($plan['price_id_valid'] ?? false); + }) + ->values() + ->all(); + + if ($invalidPlanKeys !== []) { + $blockers[] = 'Stripe price IDs are malformed for: '.implode(', ', $invalidPlanKeys).'. Use real price object IDs that start with price_.'; + } + + $status = $blockers !== [] + ? 'BLOCKED' + : ($warnings !== [] ? 'WARNING' : 'OK'); + + return [ + 'environment' => app()->environment(), + 'app_url' => config('app.url'), + 'academy_enabled' => $academyEnabled, + 'academy_billing_enabled' => $billingEnabled, + 'subscription_name' => $this->plans->subscriptionName(), + 'cashier_path' => (string) config('cashier.path', 'stripe'), + 'stripe' => [ + 'publishable_key_configured' => $this->isConfiguredSecret($stripeKey, 'pk_'), + 'secret_key_configured' => $this->isConfiguredSecret($stripeSecret, 'sk_'), + 'webhook_secret_configured' => $this->isConfiguredSecret($webhookSecret, 'whsec_'), + 'currency' => $currency, + 'currency_locale' => $currencyLocale, + ], + 'configured_plan_count' => count($planSummaries), + 'missing_plan_keys' => $missingPlanKeys, + 'invalid_plan_keys' => $invalidPlanKeys, + 'plan_summaries' => $planSummaries, + 'routes' => $routes, + 'tables' => $tables, + 'user_billing_columns' => $userBillingColumns, + 'users_billing_columns_present' => ! in_array(false, $userBillingColumns, true), + 'blockers' => array_values(array_unique($blockers)), + 'warnings' => array_values(array_unique($warnings)), + 'status' => $status, + ]; + } + + /** + * @return array{present: bool, url: string|null} + */ + private function routeStatus(string $name): array + { + if (! Route::has($name)) { + return [ + 'present' => false, + 'url' => null, + ]; + } + + return [ + 'present' => true, + 'url' => route($name), + ]; + } + + private function isConfiguredSecret(string $value, string $expectedPrefix): bool + { + $value = trim($value); + + if ($value === '' || ! str_starts_with($value, $expectedPrefix)) { + return false; + } + + return ! str_contains(strtolower($value), 'xxx'); + } + + /** + * @param array $report + */ + private function exitCodeFor(array $report): int + { + if ((bool) $this->option('strict') && $report['status'] === 'BLOCKED') { + return self::FAILURE; + } + + return self::SUCCESS; + } +} \ No newline at end of file diff --git a/app/Console/Commands/BuildWorldWebStoryAssetsCommand.php b/app/Console/Commands/BuildWorldWebStoryAssetsCommand.php new file mode 100644 index 00000000..3984513a --- /dev/null +++ b/app/Console/Commands/BuildWorldWebStoryAssetsCommand.php @@ -0,0 +1,103 @@ +argument('story'); + $force = (bool) $this->option('force'); + $dryRun = (bool) $this->option('dry-run'); + + if ($storyKey !== null && trim((string) $storyKey) !== '') { + $story = $this->resolveStory((string) $storyKey); + + if (! $story instanceof WorldWebStory) { + $this->error(sprintf('Web story [%s] was not found.', (string) $storyKey)); + + return self::FAILURE; + } + + return $this->buildOne($assets, $story, $force, $dryRun); + } + + return $this->buildBatch($assets, $force, $dryRun, max(1, (int) $this->option('limit'))); + } + + private function buildOne(WorldWebStoryAssetService $assets, WorldWebStory $story, bool $force, bool $dryRun): int + { + $result = $assets->buildAssets($story, force: $force, dryRun: $dryRun); + + $this->line(sprintf('Story [%d] %s', (int) $story->id, (string) $story->slug)); + $this->line($result['updated'] ? 'Assets updated.' : 'No asset changes needed.'); + + foreach ((array) $result['story'] as $field => $value) { + $this->line(sprintf(' - story.%s = %s', (string) $field, (string) $value)); + } + + foreach ((array) $result['pages'] as $pageId => $changes) { + foreach ((array) $changes as $field => $value) { + $this->line(sprintf(' - page.%d.%s = %s', (int) $pageId, (string) $field, (string) $value)); + } + } + + return self::SUCCESS; + } + + private function buildBatch(WorldWebStoryAssetService $assets, bool $force, bool $dryRun, int $limit): int + { + $processed = 0; + $updated = 0; + + $this->storyQuery() + ->limit($limit) + ->get() + ->each(function (WorldWebStory $story) use ($assets, $force, $dryRun, &$processed, &$updated): void { + $processed++; + $result = $assets->buildAssets($story, force: $force, dryRun: $dryRun); + + if ($result['updated']) { + $updated++; + } + + $this->line(sprintf('[%d] %s -> %s', (int) $story->id, (string) $story->slug, $result['updated'] ? 'updated' : 'unchanged')); + }); + + $this->info(sprintf('Done. processed=%d updated=%d', $processed, $updated)); + + return self::SUCCESS; + } + + private function storyQuery() + { + return WorldWebStory::query() + ->when((bool) $this->option('published'), fn ($query) => $query->published()) + ->when((bool) $this->option('visible'), fn ($query) => $query->visible()) + ->orderByDesc('published_at') + ->orderByDesc('id'); + } + + private function resolveStory(string $value): ?WorldWebStory + { + return WorldWebStory::query() + ->when(is_numeric($value), fn ($query) => $query->where('id', (int) $value), fn ($query) => $query->where('slug', $value)) + ->first(); + } +} \ No newline at end of file diff --git a/app/Console/Commands/GenerateAcademyPromptThumbnailsCommand.php b/app/Console/Commands/GenerateAcademyPromptThumbnailsCommand.php new file mode 100644 index 00000000..c461915c --- /dev/null +++ b/app/Console/Commands/GenerateAcademyPromptThumbnailsCommand.php @@ -0,0 +1,390 @@ + + */ + private const VARIANT_WIDTHS = [ + 'thumb' => 480, + 'md' => 960, + ]; + + private const PREVIEW_WEBP_QUALITY = 84; + + private const LESSON_MEDIA_WEBP_QUALITY = 85; + + protected $signature = 'academy:prompts:generate-missing-thumbnails + {--id=* : Restrict to one or more prompt IDs} + {--slug=* : Restrict to one or more prompt slugs} + {--limit= : Stop after processing this many prompts} + {--force : Regenerate variants even when they already exist} + {--dry-run : Report planned thumbnail work without writing files or saving prompt JSON}'; + + protected $description = 'Generate missing prompt preview and comparison thumbnails for existing Academy prompts'; + + public function handle(): int + { + if (! function_exists('imagecreatefromstring') || ! function_exists('imagewebp')) { + $this->error('GD WebP support is required to generate prompt thumbnails.'); + + return self::FAILURE; + } + + $ids = collect((array) $this->option('id')) + ->map(static fn (mixed $id): int => (int) $id) + ->filter(static fn (int $id): bool => $id > 0) + ->values() + ->all(); + + $slugs = collect((array) $this->option('slug')) + ->map(static fn (mixed $slug): string => trim((string) $slug)) + ->filter(static fn (string $slug): bool => $slug !== '') + ->values() + ->all(); + + $limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null; + $force = (bool) $this->option('force'); + $dryRun = (bool) $this->option('dry-run'); + + $query = AcademyPromptTemplate::query() + ->select(['id', 'slug', 'title', 'preview_image', 'tool_notes']) + ->orderBy('id'); + + if ($ids !== []) { + $query->whereIn('id', $ids); + } + + if ($slugs !== []) { + $query->whereIn('slug', $slugs); + } + + $processed = 0; + $changed = 0; + $generatedVariants = 0; + $plannedVariants = 0; + $skipped = 0; + $failed = 0; + + $query->chunkById(100, function ($prompts) use ($limit, $force, $dryRun, &$processed, &$changed, &$generatedVariants, &$plannedVariants, &$skipped, &$failed) { + foreach ($prompts as $prompt) { + if ($limit !== null && $processed >= $limit) { + return false; + } + + try { + $result = $this->backfillPrompt($prompt, $force, $dryRun); + + $generatedVariants += (int) ($result['generated_variants'] ?? 0); + $plannedVariants += (int) ($result['planned_variants'] ?? 0); + + if (($result['changed'] ?? false) === true) { + $changed++; + } else { + $skipped++; + } + } catch (Throwable $e) { + $failed++; + $this->warn(sprintf('Prompt %d (%s) failed: %s', (int) $prompt->id, (string) $prompt->slug, $e->getMessage())); + } + + $processed++; + } + + return true; + }); + + $this->info(sprintf( + 'Prompt thumbnail backfill complete. processed=%d changed=%d generated_variants=%d planned_variants=%d skipped=%d failed=%d', + $processed, + $changed, + $generatedVariants, + $plannedVariants, + $skipped, + $failed, + )); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } + + /** + * @return array{changed:bool,generated_variants:int,planned_variants:int} + */ + private function backfillPrompt(AcademyPromptTemplate $prompt, bool $force, bool $dryRun): array + { + $generatedVariants = 0; + $plannedVariants = 0; + $changed = false; + + $previewResult = $this->ensureManagedImageVariants((string) ($prompt->preview_image ?? ''), $force, $dryRun); + $generatedVariants += $previewResult['generated_variants']; + $plannedVariants += $previewResult['planned_variants']; + $changed = $changed || $previewResult['changed']; + + $notes = is_array($prompt->tool_notes) ? $prompt->tool_notes : []; + $nextNotes = []; + + foreach ($notes as $note) { + if (! is_array($note)) { + $nextNotes[] = $note; + continue; + } + + $noteResult = $this->ensurePromptComparisonNoteVariants($note, $force, $dryRun); + $generatedVariants += $noteResult['generated_variants']; + $plannedVariants += $noteResult['planned_variants']; + $changed = $changed || $noteResult['changed']; + $nextNotes[] = $noteResult['note']; + } + + if ($changed && ! $dryRun && $nextNotes !== $notes) { + $prompt->forceFill([ + 'tool_notes' => $nextNotes, + ])->save(); + } + + return [ + 'changed' => $changed, + 'generated_variants' => $generatedVariants, + 'planned_variants' => $plannedVariants, + ]; + } + + /** + * @param array $note + * @return array{note:array,changed:bool,generated_variants:int,planned_variants:int} + */ + private function ensurePromptComparisonNoteVariants(array $note, bool $force, bool $dryRun): array + { + $imagePath = trim((string) ($note['image_path'] ?? '')); + + if (! $this->isManagedLessonMediaPath($imagePath)) { + return [ + 'note' => $note, + 'changed' => false, + 'generated_variants' => 0, + 'planned_variants' => 0, + ]; + } + + $variants = $this->ensureManagedImageVariants($imagePath, $force, $dryRun); + $thumbPath = $variants['thumb_path'] ?? ''; + + if ($thumbPath === '') { + $thumbPath = $imagePath; + } + + $nextNote = $note; + $currentThumbPath = trim((string) ($note['thumb_path'] ?? '')); + + if ($currentThumbPath !== $thumbPath) { + $nextNote['thumb_path'] = $thumbPath; + $variants['changed'] = true; + } + + return [ + 'note' => $nextNote, + 'changed' => (bool) $variants['changed'], + 'generated_variants' => (int) $variants['generated_variants'], + 'planned_variants' => (int) $variants['planned_variants'], + ]; + } + + /** + * @return array{thumb_path:string,changed:bool,generated_variants:int,planned_variants:int} + */ + private function ensureManagedImageVariants(string $path, bool $force, bool $dryRun): array + { + $path = trim($path); + + if (! $this->isManagedPromptPreviewPath($path) && ! $this->isManagedLessonMediaPath($path)) { + return [ + 'thumb_path' => '', + 'changed' => false, + 'generated_variants' => 0, + 'planned_variants' => 0, + ]; + } + + $source = $this->openManagedImage($path); + + try { + $generatedVariants = 0; + $plannedVariants = 0; + $changed = false; + $thumbPath = $path; + + foreach (self::VARIANT_WIDTHS as $variant => $targetWidth) { + $status = $this->ensureVariantForWidth( + $source['image'], + $source['width'], + $source['height'], + $path, + $variant, + $targetWidth, + $force, + $dryRun, + ); + + if ($variant === 'thumb' && $source['width'] > $targetWidth) { + $thumbPath = $this->variantPath($path, 'thumb'); + } + + if ($status === 'generated') { + $generatedVariants++; + $changed = true; + } + + if ($status === 'planned') { + $plannedVariants++; + $changed = true; + } + } + + return [ + 'thumb_path' => $thumbPath, + 'changed' => $changed, + 'generated_variants' => $generatedVariants, + 'planned_variants' => $plannedVariants, + ]; + } finally { + imagedestroy($source['image']); + } + } + + /** + * @return array{image:\GdImage,width:int,height:int} + */ + private function openManagedImage(string $path): array + { + $disk = Storage::disk($this->storageDisk()); + + if (! $disk->exists($path)) { + throw new RuntimeException(sprintf('Source image is missing: %s', $path)); + } + + $binary = $disk->get($path); + + if (! is_string($binary) || $binary === '') { + throw new RuntimeException(sprintf('Source image could not be read: %s', $path)); + } + + $image = @imagecreatefromstring($binary); + + if (! $image instanceof \GdImage) { + throw new RuntimeException(sprintf('Source image is not a supported raster image: %s', $path)); + } + + if (! imageistruecolor($image)) { + imagepalettetotruecolor($image); + } + + imagealphablending($image, true); + imagesavealpha($image, true); + + return [ + 'image' => $image, + 'width' => imagesx($image), + 'height' => imagesy($image), + ]; + } + + private function ensureVariantForWidth(\GdImage $source, int $sourceWidth, int $sourceHeight, string $sourcePath, string $variant, int $targetWidth, bool $force, bool $dryRun): string + { + if ($sourceWidth <= $targetWidth || $sourceWidth < 1 || $sourceHeight < 1) { + return 'skipped'; + } + + $variantPath = $this->variantPath($sourcePath, $variant); + $disk = Storage::disk($this->storageDisk()); + + if (! $force && $disk->exists($variantPath)) { + return 'skipped'; + } + + if ($dryRun) { + return 'planned'; + } + + $targetHeight = max(1, (int) round(($sourceHeight / $sourceWidth) * $targetWidth)); + $canvas = imagecreatetruecolor($targetWidth, $targetHeight); + + if (! $canvas instanceof \GdImage) { + throw new RuntimeException(sprintf('Could not allocate variant canvas for %s', $sourcePath)); + } + + imagealphablending($canvas, false); + imagesavealpha($canvas, true); + $transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127); + imagefilledrectangle($canvas, 0, 0, $targetWidth, $targetHeight, $transparent); + imagecopyresampled($canvas, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $sourceWidth, $sourceHeight); + + try { + ob_start(); + $converted = imagewebp($canvas, null, $this->qualityForPath($sourcePath)); + $webpBinary = ob_get_clean(); + + if (! $converted || ! is_string($webpBinary) || $webpBinary === '') { + throw new RuntimeException(sprintf('Could not encode %s variant for %s', $variant, $sourcePath)); + } + + $disk->put($variantPath, $webpBinary, ['visibility' => 'public']); + } finally { + imagedestroy($canvas); + } + + return 'generated'; + } + + private function variantPath(string $path, string $variant): string + { + $directory = pathinfo($path, PATHINFO_DIRNAME); + $filename = pathinfo($path, PATHINFO_FILENAME); + $baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename; + + return sprintf('%s/%s-%s.webp', $directory, $baseFilename, $variant); + } + + private function isManagedPromptPreviewPath(string $path): bool + { + return $this->isLocalPath($path) && str_starts_with($path, self::PROMPT_PREVIEW_PREFIX . '/'); + } + + private function isManagedLessonMediaPath(string $path): bool + { + return $this->isLocalPath($path) + && (str_starts_with($path, 'academy/lessons/body/') || str_starts_with($path, 'academy/lessons/covers/')); + } + + private function isLocalPath(string $path): bool + { + return $path !== '' + && ! str_starts_with($path, 'http://') + && ! str_starts_with($path, 'https://') + && ! str_starts_with($path, '/'); + } + + private function storageDisk(): string + { + return (string) config('uploads.object_storage.disk', 's3'); + } + + private function qualityForPath(string $path): int + { + return $this->isManagedPromptPreviewPath($path) + ? self::PREVIEW_WEBP_QUALITY + : self::LESSON_MEDIA_WEBP_QUALITY; + } +} \ No newline at end of file diff --git a/app/Console/Commands/GenerateWorldWebStoriesCommand.php b/app/Console/Commands/GenerateWorldWebStoriesCommand.php new file mode 100644 index 00000000..fd5efded --- /dev/null +++ b/app/Console/Commands/GenerateWorldWebStoriesCommand.php @@ -0,0 +1,131 @@ +argument('world'); + $force = (bool) $this->option('force'); + $publish = (bool) $this->option('publish'); + $dryRun = (bool) $this->option('dry-run'); + $pages = max(5, min(10, (int) $this->option('pages'))); + + if ($worldKey !== null && trim((string) $worldKey) !== '') { + $world = $this->resolveWorld((string) $worldKey); + + if (! $world instanceof World) { + $this->error(sprintf('World [%s] was not found.', (string) $worldKey)); + + return self::FAILURE; + } + + return $this->generateOne($generator, $world, $pages, $force, $publish, $dryRun); + } + + if (! (bool) $this->option('all')) { + $this->error('Provide a world ID/slug or pass --all for batch generation.'); + + return self::INVALID; + } + + return $this->generateBatch($generator, $pages, $force, $publish, $dryRun, max(1, (int) $this->option('limit'))); + } + + private function generateOne(WorldWebStoryGenerator $generator, World $world, int $pages, bool $force, bool $publish, bool $dryRun): int + { + try { + $result = $generator->generateFromWorld($world, null, $pages, $force, $publish, $dryRun); + } catch (ValidationException $exception) { + foreach ($exception->errors() as $messages) { + foreach ($messages as $message) { + $this->error((string) $message); + } + } + + return self::FAILURE; + } + + $story = $result['story']; + $validation = $result['validation']; + + $this->info(sprintf( + '%s story for world [%s] -> /web-stories/%s (%d pages)', + $result['created'] ? 'Created' : 'Updated', + (string) $world->slug, + (string) $story->slug, + (int) $validation['page_count'], + )); + + foreach ((array) $validation['warnings'] as $warning) { + $this->warn(' - ' . $warning); + } + + foreach ((array) $validation['errors'] as $error) { + $this->error(' - ' . $error); + } + + return $validation['valid'] || ! $publish ? self::SUCCESS : self::FAILURE; + } + + private function generateBatch(WorldWebStoryGenerator $generator, int $pages, bool $force, bool $publish, bool $dryRun, int $limit): int + { + $processed = 0; + $created = 0; + $updated = 0; + $failed = 0; + + $query = World::query() + ->published() + ->orderByDesc('published_at') + ->orderByDesc('id'); + + if (! $force) { + $query->whereDoesntHave('webStories'); + } + + $query->limit($limit)->get()->each(function (World $world) use ($generator, $pages, $force, $publish, $dryRun, &$processed, &$created, &$updated, &$failed): void { + $processed++; + + try { + $result = $generator->generateFromWorld($world, null, $pages, $force, $publish, $dryRun); + $result['created'] ? $created++ : $updated++; + $this->line(sprintf('[%d] %s -> %s', (int) $world->id, (string) $world->slug, (string) $result['story']->slug)); + } catch (ValidationException $exception) { + $failed++; + $first = collect($exception->errors())->flatten()->first(); + $this->error(sprintf('[%d] %s failed: %s', (int) $world->id, (string) $world->slug, (string) $first)); + } + }); + + $this->info(sprintf('Done. processed=%d created=%d updated=%d failed=%d', $processed, $created, $updated, $failed)); + + return $failed === 0 ? self::SUCCESS : self::FAILURE; + } + + private function resolveWorld(string $value): ?World + { + return World::query() + ->when(is_numeric($value), fn ($query) => $query->where('id', (int) $value), fn ($query) => $query->where('slug', $value)) + ->first(); + } +} \ No newline at end of file diff --git a/app/Console/Commands/HealthCheckCommand.php b/app/Console/Commands/HealthCheckCommand.php index 4ecdf879..f7455d2f 100644 --- a/app/Console/Commands/HealthCheckCommand.php +++ b/app/Console/Commands/HealthCheckCommand.php @@ -5,8 +5,10 @@ declare(strict_types=1); namespace App\Console\Commands; use App\Models\Artwork; +use App\Services\Sitemaps\SitemapReleaseManager; use App\Services\Vision\ArtworkVisionImageUrl; use Illuminate\Console\Command; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; @@ -25,10 +27,10 @@ use Throwable; class HealthCheckCommand extends Command { protected $signature = 'health:check - {--only= : Run only a named check (mysql|redis|cache|meilisearch|qdrant|reverb|vision|horizon|webserver|phpfpm|paths|ram|disk|load|s3|failed_jobs|queue_backlog|ssl|scheduler|log_errors|app)} + {--only= : Run only a named check (mysql|redis|cache|meilisearch|qdrant|reverb|vision|horizon|webserver|phpfpm|paths|ram|disk|load|s3|failed_jobs|queue_backlog|ssl|scheduler|sitemap|log_errors|app)} {--json : Output results as JSON}'; - protected $description = 'Check health of all critical services (MySQL, Redis, Cache, Meilisearch, Qdrant, Reverb, Vision, Horizon, Nginx, PHP-FPM, writable paths, RAM, disk, load, S3/Contabo, failed jobs, queue backlog, SSL, scheduler, log errors, App).'; + protected $description = 'Check health of all critical services (MySQL, Redis, Cache, Meilisearch, Qdrant, Reverb, Vision, Horizon, Nginx, PHP-FPM, writable paths, RAM, disk, load, S3/Contabo, failed jobs, queue backlog, SSL, scheduler, sitemap, log errors, App).'; /** Collected results: [name => [status, message, details]] */ private array $results = []; @@ -57,6 +59,7 @@ class HealthCheckCommand extends Command 'queue_backlog' => fn () => $this->checkQueueBacklog(), 'ssl' => fn () => $this->checkSsl(), 'scheduler' => fn () => $this->checkScheduler(), + 'sitemap' => fn () => $this->checkSitemap(), 'log_errors' => fn () => $this->checkLogErrors(), 'app' => fn () => $this->checkApp(), ]; @@ -1041,6 +1044,51 @@ class HealthCheckCommand extends Command } } + private function checkSitemap(): void + { + try { + $releases = app(SitemapReleaseManager::class)->listReleases(); + + if ($releases === []) { + $this->failCheck('sitemap', 'No sitemap releases found. Run `php artisan skinbase:sitemaps:publish` to build one.'); + return; + } + + $latest = $releases[0]; + $releaseId = (string) ($latest['release_id'] ?? 'unknown'); + $builtAtRaw = (string) ($latest['built_at'] ?? $latest['published_at'] ?? ''); + + if ($builtAtRaw === '') { + $this->warn_check('sitemap', "Latest sitemap release [{$releaseId}] is missing a build timestamp.", [ + 'release_id' => $releaseId, + 'status' => (string) ($latest['status'] ?? 'unknown'), + ]); + return; + } + + $builtAt = Carbon::parse($builtAtRaw); + $ageSeconds = max(0, $builtAt->diffInSeconds(now())); + $builtAtLabel = $builtAt->toAtomString(); + $details = [ + 'release_id' => $releaseId, + 'built_at' => $builtAtLabel, + 'age_seconds' => $ageSeconds, + 'status' => (string) ($latest['status'] ?? 'unknown'), + ]; + $message = "Latest sitemap release [{$releaseId}] built at {$builtAtLabel} ({$ageSeconds}s ago)."; + + if ($ageSeconds > 72 * 3600) { + $this->failCheck('sitemap', 'Sitemap build is stale — ' . $message, $details); + } elseif ($ageSeconds > 36 * 3600) { + $this->warn_check('sitemap', 'Sitemap build is getting old — ' . $message, $details); + } else { + $this->pass('sitemap', $message, $details); + } + } catch (Throwable $e) { + $this->warn_check('sitemap', 'Could not inspect sitemap releases: ' . $e->getMessage()); + } + } + private function checkLogErrors(): void { $logFile = storage_path('logs/laravel.log'); diff --git a/app/Console/Commands/ValidateWorldWebStoriesCommand.php b/app/Console/Commands/ValidateWorldWebStoriesCommand.php new file mode 100644 index 00000000..c40083af --- /dev/null +++ b/app/Console/Commands/ValidateWorldWebStoriesCommand.php @@ -0,0 +1,163 @@ +argument('story'); + + if ($storyKey !== null && trim((string) $storyKey) !== '') { + $story = $this->resolveStory((string) $storyKey); + + if (! $story instanceof WorldWebStory) { + $this->error(sprintf('Web story [%s] was not found.', (string) $storyKey)); + + return self::FAILURE; + } + + return $this->validateOne($validation, $story); + } + + return $this->validateBatch($validation, max(1, (int) $this->option('limit'))); + } + + private function validateOne(WorldWebStoryValidationService $validation, WorldWebStory $story): int + { + $result = $validation->validate($story); + $ampErrors = $this->ampErrors($story); + + $this->line(sprintf('Story [%d] %s', (int) $story->id, (string) $story->slug)); + + foreach ((array) $result['warnings'] as $warning) { + $this->warn(' - ' . $warning); + } + + foreach ((array) $result['errors'] as $error) { + $this->error(' - ' . $error); + } + + foreach ($ampErrors as $ampError) { + $this->error(' - AMP: ' . $ampError); + } + + if ($result['valid'] && $ampErrors === []) { + $this->info('Validation passed.'); + + return self::SUCCESS; + } + + return self::FAILURE; + } + + private function validateBatch(WorldWebStoryValidationService $validation, int $limit): int + { + $processed = 0; + $failed = 0; + + $this->storyQuery() + ->limit($limit) + ->get() + ->each(function (WorldWebStory $story) use ($validation, &$processed, &$failed): void { + $processed++; + $result = $validation->validate($story); + $ampErrors = $this->ampErrors($story); + $warningsFail = (bool) $this->option('fail-warnings') && count((array) $result['warnings']) > 0; + $hasFailure = ! $result['valid'] || $warningsFail || $ampErrors !== []; + + if ($hasFailure) { + $failed++; + } + + $this->line(sprintf('[%d] %s -> %s', (int) $story->id, (string) $story->slug, $hasFailure ? 'invalid' : 'valid')); + + foreach ((array) $result['warnings'] as $warning) { + $this->warn(' - ' . $warning); + } + + foreach ((array) $result['errors'] as $error) { + $this->error(' - ' . $error); + } + + foreach ($ampErrors as $ampError) { + $this->error(' - AMP: ' . $ampError); + } + }); + + $this->info(sprintf('Done. processed=%d failed=%d', $processed, $failed)); + + return $failed === 0 ? self::SUCCESS : self::FAILURE; + } + + private function storyQuery() + { + return WorldWebStory::query() + ->when((bool) $this->option('published'), fn ($query) => $query->published()) + ->when((bool) $this->option('visible'), fn ($query) => $query->visible()) + ->orderByDesc('published_at') + ->orderByDesc('id'); + } + + /** + * @return list + */ + private function ampErrors(WorldWebStory $story): array + { + if (! (bool) $this->option('amp')) { + return []; + } + + if (! $story->exists || ! $story->publicUrl()) { + return ['Story has no public URL to validate.']; + } + + $probe = new Process(['npx', 'amphtml-validator', '--version'], base_path(), null, null, 60); + $probe->run(); + + if (! $probe->isSuccessful()) { + return ['amphtml-validator is not available via npx.']; + } + + $process = new Process(['npx', 'amphtml-validator', $story->publicUrl()], base_path(), null, null, 120); + $process->run(); + + if ($process->isSuccessful()) { + return []; + } + + $output = trim($process->getErrorOutput() ?: $process->getOutput()); + + if ($output === '') { + return ['AMP validator failed without output.']; + } + + $lines = preg_split('/\r\n|\r|\n/', $output); + + return $lines === false || $lines === [] ? ['AMP validator failed.'] : $lines; + } + + private function resolveStory(string $value): ?WorldWebStory + { + return WorldWebStory::query() + ->when(is_numeric($value), fn ($query) => $query->where('id', (int) $value), fn ($query) => $query->where('slug', $value)) + ->first(); + } +} \ No newline at end of file diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 44dd39de..ee0487f4 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -31,10 +31,13 @@ use App\Console\Commands\PublishScheduledArtworksCommand; use App\Console\Commands\PublishScheduledNewsCommand; use App\Console\Commands\PublishScheduledNovaCardsCommand; use App\Console\Commands\BuildSitemapsCommand; +use App\Console\Commands\BuildWorldWebStoryAssetsCommand; use App\Console\Commands\ListSitemapReleasesCommand; +use App\Console\Commands\GenerateWorldWebStoriesCommand; use App\Console\Commands\PublishSitemapsCommand; use App\Console\Commands\RollbackSitemapReleaseCommand; use App\Console\Commands\SyncCollectionLifecycleCommand; +use App\Console\Commands\ValidateWorldWebStoriesCommand; use App\Console\Commands\ValidateSitemapsCommand; use App\Console\Commands\AuditArtworkDownloadFilesCommand; use App\Console\Commands\InspectArtworkOriginalCommand; @@ -58,6 +61,9 @@ class Kernel extends ConsoleKernel \App\Console\Commands\ResetAllUserPasswords::class, CleanupUploadsCommand::class, BuildSitemapsCommand::class, + GenerateWorldWebStoriesCommand::class, + BuildWorldWebStoryAssetsCommand::class, + ValidateWorldWebStoriesCommand::class, PublishSitemapsCommand::class, ListSitemapReleasesCommand::class, RollbackSitemapReleaseCommand::class, diff --git a/app/Http/Controllers/Academy/AcademyAnalyticsEventController.php b/app/Http/Controllers/Academy/AcademyAnalyticsEventController.php new file mode 100644 index 00000000..b118e3ae --- /dev/null +++ b/app/Http/Controllers/Academy/AcademyAnalyticsEventController.php @@ -0,0 +1,94 @@ +expectsJson() || $request->isJson(), 422); + + $validated = $request->validate([ + 'event_type' => ['required', 'string', Rule::in(AcademyAnalyticsEventType::values())], + 'content_type' => ['nullable', 'string', Rule::in(AcademyAnalyticsContentType::values())], + 'content_id' => ['nullable', 'integer', 'min:1'], + 'metadata' => ['nullable', 'array'], + 'visitor_id' => ['nullable', 'string', 'max:120'], + 'session_id' => ['nullable', 'string', 'max:120'], + 'url' => ['nullable', 'string', 'max:4000'], + 'route_name' => ['nullable', 'string', 'max:255'], + 'referrer' => ['nullable', 'string', 'max:4000'], + 'utm_source' => ['nullable', 'string', 'max:255'], + 'utm_medium' => ['nullable', 'string', 'max:255'], + 'utm_campaign' => ['nullable', 'string', 'max:255'], + ]); + + if (isset($validated['metadata']) && strlen((string) json_encode($validated['metadata'])) > 8192) { + return response()->json([ + 'message' => 'Metadata payload is too large.', + ], 422); + } + + $contentType = $validated['content_type'] ?? null; + $contentId = $validated['content_id'] ?? null; + + if ($contentType !== null && AcademyAnalyticsContentType::requiresContentId($contentType) && $contentId === null) { + return response()->json([ + 'message' => 'content_id is required for this content type.', + ], 422); + } + + if ($contentType !== null && $contentId !== null && ! $this->resolver->exists($contentType, (int) $contentId)) { + return response()->json([ + 'message' => 'Unknown Academy analytics content target.', + ], 422); + } + + if (($validated['event_type'] ?? null) === AcademyAnalyticsEventType::SEARCH_RESULT_CLICK) { + validator([ + 'content_type' => $contentType, + 'content_id' => $contentId, + 'metadata' => $validated['metadata'] ?? [], + ], [ + 'content_type' => ['required', 'string', Rule::in([ + AcademyAnalyticsContentType::PROMPT, + AcademyAnalyticsContentType::LESSON, + AcademyAnalyticsContentType::COURSE, + AcademyAnalyticsContentType::PROMPT_PACK, + AcademyAnalyticsContentType::CHALLENGE, + ])], + 'content_id' => ['required', 'integer', 'min:1'], + 'metadata.query' => ['required', 'string', 'max:120'], + 'metadata.normalized_query' => ['required', 'string', 'max:120'], + 'metadata.results_count' => ['required', 'integer', 'min:0'], + 'metadata.position' => ['nullable', 'integer', 'min:1'], + 'metadata.source' => ['nullable', 'string', 'max:120'], + 'metadata.filters' => ['nullable', 'array'], + ])->validate(); + } + + $this->analytics->track($validated, $request->user(), $request); + + return response()->json([ + 'ok' => true, + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Academy/AcademyBillingController.php b/app/Http/Controllers/Academy/AcademyBillingController.php new file mode 100644 index 00000000..f2faf29b --- /dev/null +++ b/app/Http/Controllers/Academy/AcademyBillingController.php @@ -0,0 +1,422 @@ +plans->assertConfigured(); + + /** @var User|null $user */ + $user = $request->user(); + $canonical = \route('academy.pricing'); + $seo = \app(SeoFactory::class) + ->collectionPage( + 'Skinbase AI Academy Pricing — Skinbase', + 'Compare Skinbase AI Academy Creator and Pro tiers, start free, and manage premium access through Stripe billing.', + $canonical, + ) + ->toArray(); + + $seo['og_type'] = 'website'; + $activePlan = $user instanceof User ? $this->activePlan($user) : null; + + return \Inertia\Inertia::render('Academy/Billing/Pricing', [ + 'seo' => $seo, + 'billingEnabled' => $this->plans->enabled(), + 'currentTier' => $this->access->currentTier($user), + 'isSubscribed' => $user instanceof User ? $this->access->hasActiveAcademySubscription($user) : false, + 'activePlanKey' => $activePlan['key'] ?? null, + 'activePlanLabel' => $activePlan['label'] ?? null, + 'catalog' => $this->catalog(), + 'links' => [ + 'login' => \route('login'), + 'pricing' => \route('academy.pricing'), + 'billingAccount' => $user ? \route('academy.billing.account') : null, + 'checkout' => $user ? \route('academy.billing.checkout') : null, + ], + 'analytics' => [ + 'enabled' => true, + 'contentType' => AcademyAnalyticsContentType::UPGRADE, + 'contentId' => null, + 'eventUrl' => \route('academy.analytics.events.store'), + 'pageName' => 'academy_billing_pricing', + 'isPremium' => false, + 'isGuest' => $user === null, + 'isSubscriber' => $user?->hasAcademyCreatorAccess() || $user?->hasAcademyProAccess(), + ], + ])->rootView('collections'); + } + + public function checkout(\Illuminate\Http\Request $request): Checkout|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse + { + \abort_unless((bool) \config('academy.enabled', true), 404); + + if (! $this->plans->enabled()) { + return $this->billingDisabledResponse($request, 'Academy billing is not enabled yet.'); + } + + $this->plans->assertConfigured(); + + $user = $request->user(); + + if (! $user instanceof User) { + return \redirect()->route('login'); + } + + if ($user->email_verified_at === null) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'plan' => 'Verify your email address before starting Academy billing.', + ]); + } + + $validated = $request->validate([ + 'plan' => ['required', 'string'], + ]); + + $plan = $this->plans->plan((string) $validated['plan']); + + if ($plan === null) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'plan' => 'Select a valid Academy billing plan.', + ]); + } + + if (! ($plan['configured'] ?? false)) { + return $this->missingPriceIdResponse($request, (string) $plan['key']); + } + + if (! ($plan['price_id_valid'] ?? false)) { + return $this->invalidPriceIdResponse($request, (string) $plan['key']); + } + + if ($this->access->hasActiveAcademySubscription($user)) { + return \redirect()->route('academy.billing.portal'); + } + + try { + return $user + ->newSubscription($this->plans->subscriptionName(), (string) $plan['stripe_price_id']) + ->withMetadata([ + 'skinbase_module' => 'academy', + 'user_id' => (string) $user->id, + 'academy_plan' => (string) $plan['key'], + 'academy_tier' => (string) $plan['tier'], + ]) + ->checkout([ + 'success_url' => \route('academy.billing.success').'?session_id={CHECKOUT_SESSION_ID}', + 'cancel_url' => \route('academy.billing.cancel'), + 'allow_promotion_codes' => true, + 'metadata' => [ + 'skinbase_module' => 'academy', + 'user_id' => (string) $user->id, + 'academy_plan' => (string) $plan['key'], + 'academy_tier' => (string) $plan['tier'], + ], + ]); + } catch (\Throwable $exception) { + \report($exception); + + return $this->checkoutErrorResponse($request, $exception); + } + } + + public function checkoutLegacy(\Illuminate\Http\Request $request, string $plan): Checkout|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse + { + $request->merge([ + 'plan' => $this->plans->normalizePlanKey($plan), + ]); + + return $this->checkout($request); + } + + public function portal(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse + { + \abort_unless((bool) \config('academy.enabled', true), 404); + \abort_unless($this->plans->enabled(), 404); + + /** @var User|null $user */ + $user = $request->user(); + + if (! $user instanceof User || \blank($user->stripe_id)) { + return \redirect()->route('academy.billing.account')->with('error', 'No Stripe billing profile is connected to this account yet.'); + } + + return $user->redirectToBillingPortal(\route('academy.billing.account')); + } + + public function success(\Illuminate\Http\Request $request): \Inertia\Response + { + \abort_unless((bool) \config('academy.enabled', true), 404); + + /** @var User|null $user */ + $user = $request->user(); + $currentTier = $this->access->currentTier($user); + + return \Inertia\Inertia::render('Academy/Billing/Success', [ + 'message' => 'Payment is being confirmed. Your access will update automatically.', + 'currentTier' => $currentTier, + 'isSubscribed' => $user instanceof User ? $this->access->hasActiveAcademySubscription($user) : false, + 'links' => [ + 'pricing' => \route('academy.pricing'), + 'account' => $user ? \route('academy.billing.account') : null, + 'academy' => \route('academy.index'), + ], + 'sessionId' => $request->query('session_id'), + ])->rootView('collections'); + } + + public function cancel(): \Inertia\Response + { + \abort_unless((bool) \config('academy.enabled', true), 404); + + return \Inertia\Inertia::render('Academy/Billing/Cancel', [ + 'message' => 'Checkout was canceled. No payment was made.', + 'links' => [ + 'pricing' => \route('academy.pricing'), + 'academy' => \route('academy.index'), + ], + ])->rootView('collections'); + } + + public function account(\Illuminate\Http\Request $request): \Inertia\Response + { + \abort_unless((bool) \config('academy.enabled', true), 404); + \abort_unless($this->plans->enabled(), 404); + + /** @var User $user */ + $user = $request->user(); + $subscription = $this->academySubscription($user); + + $activePlan = $this->activePlan($user); + + return \Inertia\Inertia::render('Academy/Billing/Account', [ + 'currentTier' => $this->access->currentTier($user), + 'isSubscribed' => $this->access->hasActiveAcademySubscription($user), + 'activePlan' => $activePlan ? [ + 'key' => $activePlan['key'], + 'label' => $activePlan['label'], + 'price_display' => $activePlan['price_display'] ?? null, + 'tier' => $activePlan['tier'], + ] : null, + 'subscription' => $subscription ? [ + 'name' => $subscription->type, + 'status' => $subscription->stripe_status, + 'active' => $subscription->active(), + 'onGracePeriod' => $subscription->onGracePeriod(), + 'endsAt' => $subscription->ends_at?->toISOString(), + 'priceIds' => $subscription->items->pluck('stripe_price')->filter()->values()->all(), + ] : null, + 'links' => [ + 'portal' => \route('academy.billing.portal'), + 'pricing' => \route('academy.pricing'), + 'academy' => \route('academy.index'), + ], + ])->rootView('collections'); + } + + /** + * @return array> + */ + private function catalog(): array + { + $definitions = [ + 'creator' => [ + 'name' => 'Creator', + 'description' => 'Entry premium access for prompt systems, creator lessons, and saved Academy workflows.', + 'badge' => 'Paid', + 'featured' => false, + 'features' => [ + 'Creator lessons and walkthroughs', + 'Full Creator prompt templates', + 'Prompt save and reuse flows', + 'Upgrade path into Pro later', + ], + ], + 'pro' => [ + 'name' => 'Pro', + 'description' => 'Full Academy access across Creator and Pro lessons, prompts, and future premium drops.', + 'badge' => 'Recommended', + 'featured' => true, + 'features' => [ + 'Everything in Creator', + 'Advanced Pro lessons and prompt systems', + 'Priority access to future Academy premium features', + 'Stripe billing portal for upgrades and invoices', + ], + ], + ]; + + return collect($definitions) + ->map(function (array $definition, string $tier): array { + $plan = $this->plans->plan($tier.'_monthly'); + + $plans = $plan !== null ? [[ + 'key' => $plan['key'], + 'label' => $plan['label'], + 'interval' => $plan['interval'], + 'amount' => $plan['amount'], + 'currency' => $plan['currency'], + 'price_display' => $plan['price_display'], + 'configured' => $plan['configured'], + 'price_id_valid' => $plan['price_id_valid'], + ]] : []; + + return [ + 'tier' => $tier, + 'name' => $definition['name'], + 'description' => $definition['description'], + 'badge' => $definition['badge'], + 'featured' => $definition['featured'], + 'features' => $definition['features'], + 'plans' => $plans, + ]; + }) + ->values() + ->all(); + } + + private function academySubscription(User $user): ?Subscription + { + $subscription = $user->subscription($this->plans->subscriptionName()); + + return $subscription instanceof Subscription + ? $subscription->loadMissing('items') + : null; + } + + /** + * @return array|null + */ + private function activePlan(User $user): ?array + { + $subscription = $this->academySubscription($user); + + if (! $subscription instanceof Subscription || (! $subscription->active() && ! $subscription->onGracePeriod())) { + return null; + } + + $matchedPlan = null; + + foreach ($subscription->items as $item) { + $priceId = trim((string) $item->stripe_price); + + if ($priceId === '') { + continue; + } + + $plan = $this->plans->planForPriceId($priceId); + + if ($plan === null) { + continue; + } + + if ($matchedPlan === null || $this->planRank((string) $plan['tier']) > $this->planRank((string) $matchedPlan['tier'])) { + $matchedPlan = $plan; + } + } + + if ($matchedPlan !== null) { + return $matchedPlan; + } + + $fallbackPriceId = trim((string) $subscription->stripe_price); + + return $fallbackPriceId !== '' ? $this->plans->planForPriceId($fallbackPriceId) : null; + } + + private function planRank(string $tier): int + { + return match (strtolower(trim($tier))) { + 'admin' => 40, + 'pro' => 30, + 'creator' => 20, + default => 10, + }; + } + + private function billingDisabledResponse(\Illuminate\Http\Request $request, string $message): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse + { + $payload = [ + 'ok' => false, + 'code' => 'academy_payments_disabled', + 'message' => $message, + ]; + + if ($request->expectsJson()) { + return \response()->json($payload, 423); + } + + return \redirect()->route('academy.pricing')->with('error', $message); + } + + private function missingPriceIdResponse(\Illuminate\Http\Request $request, string $planKey): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse + { + $message = 'The selected Academy plan is not configured yet. Please try again later.'; + + if ($request->expectsJson()) { + return \response()->json([ + 'ok' => false, + 'code' => 'academy_billing_price_missing', + 'message' => $message, + 'plan' => $planKey, + ], 422); + } + + return \redirect()->route('academy.pricing')->with('error', $message); + } + + private function invalidPriceIdResponse(\Illuminate\Http\Request $request, string $planKey): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse + { + $message = 'The selected Academy plan is misconfigured. Please contact support before continuing.'; + + if ($request->expectsJson()) { + return \response()->json([ + 'ok' => false, + 'code' => 'academy_billing_price_invalid', + 'message' => $message, + 'plan' => $planKey, + ], 422); + } + + return \redirect()->route('academy.pricing')->with('error', $message); + } + + private function checkoutErrorResponse(\Illuminate\Http\Request $request, \Throwable $exception): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse + { + $message = 'Academy checkout could not be started right now.'; + + if (app()->hasDebugModeEnabled() && trim($exception->getMessage()) !== '') { + $message .= ' '.$exception->getMessage(); + } + + if ($request->expectsJson()) { + return \response()->json([ + 'ok' => false, + 'code' => 'academy_billing_checkout_failed', + 'message' => $message, + ], 422); + } + + return \redirect()->route('academy.pricing')->with('error', $message); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Academy/AcademyChallengeController.php b/app/Http/Controllers/Academy/AcademyChallengeController.php index 128821f3..2b340608 100644 --- a/app/Http/Controllers/Academy/AcademyChallengeController.php +++ b/app/Http/Controllers/Academy/AcademyChallengeController.php @@ -7,6 +7,8 @@ namespace App\Http\Controllers\Academy; use App\Http\Controllers\Controller; use App\Models\AcademyChallenge; use App\Services\Academy\AcademyAccessService; +use App\Services\Academy\AcademyInteractionService; +use App\Support\AcademyAnalytics\AcademyAnalyticsContentType; use App\Support\Seo\SeoFactory; use Illuminate\Http\Request; use Illuminate\Support\Str; @@ -15,7 +17,10 @@ use Inertia\Response; final class AcademyChallengeController extends Controller { - public function __construct(private readonly AcademyAccessService $access) + public function __construct( + private readonly AcademyAccessService $access, + private readonly AcademyInteractionService $interactions, + ) { } @@ -49,6 +54,16 @@ final class AcademyChallengeController extends Controller 'filters' => [], 'categories' => [], 'pricingUrl' => route('academy.pricing'), + 'analytics' => [ + 'enabled' => true, + 'contentType' => null, + 'contentId' => null, + 'eventUrl' => route('academy.analytics.events.store'), + 'pageName' => 'academy_challenges_index', + 'isPremium' => false, + 'isGuest' => $request->user() === null, + 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), + ], ])->rootView('collections'); } @@ -86,12 +101,31 @@ final class AcademyChallengeController extends Controller $challenge->cover_image, )->toArray(); + $interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::CHALLENGE, (int) $challenge->id); + return Inertia::render('Academy/Show', [ 'pageType' => 'challenge', 'item' => $payload, 'seo' => $seo, 'pricingUrl' => route('academy.pricing'), 'submitUrl' => $request->user() ? route('academy.challenges.submit', ['slug' => $challenge->slug]) : null, + 'interaction' => $interaction, + 'interactionRoutes' => [ + 'like' => route('academy.interactions.like'), + 'save' => route('academy.interactions.save'), + ], + 'loginUrl' => route('login'), + 'analytics' => [ + 'enabled' => true, + 'contentType' => AcademyAnalyticsContentType::CHALLENGE, + 'contentId' => (int) $challenge->id, + 'eventUrl' => route('academy.analytics.events.store'), + 'pageName' => 'academy_challenge_show', + 'isPremium' => (string) ($challenge->access_level ?? 'free') !== 'free', + 'isGuest' => $request->user() === null, + 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), + 'isLocked' => (bool) ($payload['locked'] ?? false), + ], ])->rootView('collections'); } } \ No newline at end of file diff --git a/app/Http/Controllers/Academy/AcademyCourseController.php b/app/Http/Controllers/Academy/AcademyCourseController.php index f1fed55c..0c5ae638 100644 --- a/app/Http/Controllers/Academy/AcademyCourseController.php +++ b/app/Http/Controllers/Academy/AcademyCourseController.php @@ -8,9 +8,11 @@ use App\Http\Controllers\Controller; use App\Models\AcademyCourse; use App\Models\AcademyCourseLesson; use App\Services\Academy\AcademyAccessService; +use App\Services\Academy\AcademyInteractionService; use App\Services\Academy\AcademyCacheService; use App\Services\Academy\AcademyCourseNavigationService; use App\Services\Academy\AcademyCourseProgressService; +use App\Support\AcademyAnalytics\AcademyAnalyticsContentType; use App\Support\Seo\SeoFactory; use Illuminate\Http\Request; use Inertia\Inertia; @@ -23,6 +25,7 @@ final class AcademyCourseController extends Controller private readonly AcademyCacheService $cache, private readonly AcademyCourseNavigationService $navigation, private readonly AcademyCourseProgressService $progress, + private readonly AcademyInteractionService $interactions, ) { } @@ -82,6 +85,16 @@ final class AcademyCourseController extends Controller 'featuredCourses' => $featuredCourses->all(), 'filters' => $filters, 'pricingUrl' => route('academy.pricing'), + 'analytics' => [ + 'enabled' => true, + 'contentType' => null, + 'contentId' => null, + 'eventUrl' => route('academy.analytics.events.store'), + 'pageName' => 'academy_courses_index', + 'isPremium' => false, + 'isGuest' => $request->user() === null, + 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), + ], ])->rootView('collections'); } @@ -172,6 +185,8 @@ final class AcademyCourseController extends Controller ) ->toArray(); + $interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::COURSE, (int) $course->id); + return Inertia::render('Academy/CoursesShow', [ 'seo' => $seo, 'course' => $coursePayload, @@ -179,6 +194,23 @@ final class AcademyCourseController extends Controller 'unsectionedLessons' => $unsectionedLessons, 'pricingUrl' => route('academy.pricing'), 'startUrl' => $request->user() ? route('academy.courses.start', ['course' => $course->slug]) : null, + 'interaction' => $interaction, + 'interactionRoutes' => [ + 'like' => route('academy.interactions.like'), + 'save' => route('academy.interactions.save'), + ], + 'loginUrl' => route('login'), + 'analytics' => [ + 'enabled' => true, + 'contentType' => AcademyAnalyticsContentType::COURSE, + 'contentId' => (int) $course->id, + 'eventUrl' => route('academy.analytics.events.store'), + 'pageName' => 'academy_course_show', + 'isPremium' => (string) ($course->access_level ?? 'free') !== 'free', + 'isGuest' => $request->user() === null, + 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), + 'isLocked' => false, + ], ])->rootView('collections'); } } \ No newline at end of file diff --git a/app/Http/Controllers/Academy/AcademyCourseEnrollmentController.php b/app/Http/Controllers/Academy/AcademyCourseEnrollmentController.php index ea8993e4..ecfeed86 100644 --- a/app/Http/Controllers/Academy/AcademyCourseEnrollmentController.php +++ b/app/Http/Controllers/Academy/AcademyCourseEnrollmentController.php @@ -6,14 +6,17 @@ namespace App\Http\Controllers\Academy; use App\Http\Controllers\Controller; use App\Models\AcademyCourse; +use App\Services\Academy\AcademyProgressService; use App\Services\Academy\AcademyCourseProgressService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; final class AcademyCourseEnrollmentController extends Controller { - public function __construct(private readonly AcademyCourseProgressService $progress) - { + public function __construct( + private readonly AcademyCourseProgressService $progress, + private readonly AcademyProgressService $academyProgress, + ) { } public function start(Request $request, AcademyCourse $course): RedirectResponse @@ -21,7 +24,7 @@ final class AcademyCourseEnrollmentController extends Controller abort_unless((bool) config('academy.enabled', true), 404); abort_unless($course->isPublished(), 404); - $this->progress->markEnrollmentStarted($request->user(), $course); + $this->academyProgress->startCourse($request->user(), (int) $course->id, $request); $continueLesson = $this->progress->getContinueLesson($request->user(), $course); if ($continueLesson?->lesson) { diff --git a/app/Http/Controllers/Academy/AcademyCourseLessonController.php b/app/Http/Controllers/Academy/AcademyCourseLessonController.php index 824a16cb..4935ae65 100644 --- a/app/Http/Controllers/Academy/AcademyCourseLessonController.php +++ b/app/Http/Controllers/Academy/AcademyCourseLessonController.php @@ -8,8 +8,10 @@ use App\Http\Controllers\Controller; use App\Models\AcademyCourse; use App\Models\AcademyLesson; use App\Services\Academy\AcademyAccessService; +use App\Services\Academy\AcademyInteractionService; use App\Services\Academy\AcademyCourseNavigationService; use App\Services\Academy\AcademyCourseProgressService; +use App\Support\AcademyAnalytics\AcademyAnalyticsContentType; use App\Support\Seo\SeoFactory; use Illuminate\Support\Str; use Illuminate\Http\Request; @@ -22,6 +24,7 @@ final class AcademyCourseLessonController extends Controller private readonly AcademyAccessService $access, private readonly AcademyCourseNavigationService $navigation, private readonly AcademyCourseProgressService $progress, + private readonly AcademyInteractionService $interactions, ) { } @@ -68,6 +71,8 @@ final class AcademyCourseLessonController extends Controller (string) $course->title, )->toArray(); + $interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::LESSON, (int) $lesson->id); + return Inertia::render('Academy/Show', [ 'pageType' => 'lesson', 'item' => $payload, @@ -79,6 +84,26 @@ final class AcademyCourseLessonController extends Controller 'pricingUrl' => route('academy.pricing'), 'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null, 'completed' => $request->user()?->academyLessonProgress()->where('lesson_id', $lesson->id)->whereNotNull('completed_at')->exists() ?? false, + 'interaction' => $interaction, + 'interactionRoutes' => [ + 'like' => route('academy.interactions.like'), + 'save' => route('academy.interactions.save'), + ], + 'loginUrl' => route('login'), + 'progressRoutes' => [ + 'startLesson' => $request->user() ? route('academy.progress.lesson.start') : null, + ], + 'analytics' => [ + 'enabled' => true, + 'contentType' => AcademyAnalyticsContentType::LESSON, + 'contentId' => (int) $lesson->id, + 'eventUrl' => route('academy.analytics.events.store'), + 'pageName' => 'academy_course_lesson_show', + 'isPremium' => (string) ($payload['access_level'] ?? 'free') !== 'free', + 'isGuest' => $request->user() === null, + 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), + 'isLocked' => (bool) ($payload['locked'] ?? false), + ], 'courseContext' => [ 'id' => (int) $course->id, 'title' => (string) $course->title, diff --git a/app/Http/Controllers/Academy/AcademyHomeController.php b/app/Http/Controllers/Academy/AcademyHomeController.php index 6137f672..2bb1e329 100644 --- a/app/Http/Controllers/Academy/AcademyHomeController.php +++ b/app/Http/Controllers/Academy/AcademyHomeController.php @@ -11,6 +11,7 @@ use App\Models\AcademyLesson; use App\Models\AcademyPromptTemplate; use App\Services\Academy\AcademyAccessService; use App\Services\Academy\AcademyCacheService; +use App\Support\AcademyAnalytics\AcademyAnalyticsContentType; use App\Support\Seo\SeoFactory; use Illuminate\Http\Request; use Inertia\Inertia; @@ -81,6 +82,16 @@ final class AcademyHomeController extends Controller 'featuredLessons' => collect($home['featuredLessons'])->map(fn (AcademyLesson $lesson): array => $this->access->lessonPayload($lesson, $request->user()))->values()->all(), 'featuredPrompts' => collect($home['featuredPrompts'])->map(fn (AcademyPromptTemplate $prompt): array => $this->access->promptPayload($prompt, $request->user()))->values()->all(), 'featuredChallenges' => collect($home['featuredChallenges'])->map(fn (AcademyChallenge $challenge): array => $this->access->challengePayload($challenge, $request->user(), true))->values()->all(), + 'analytics' => [ + 'enabled' => true, + 'contentType' => AcademyAnalyticsContentType::HOME, + 'contentId' => null, + 'eventUrl' => route('academy.analytics.events.store'), + 'pageName' => 'academy_home', + 'isPremium' => false, + 'isGuest' => $request->user() === null, + 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), + ], ])->rootView('collections'); } } \ No newline at end of file diff --git a/app/Http/Controllers/Academy/AcademyInteractionController.php b/app/Http/Controllers/Academy/AcademyInteractionController.php new file mode 100644 index 00000000..e64a9897 --- /dev/null +++ b/app/Http/Controllers/Academy/AcademyInteractionController.php @@ -0,0 +1,67 @@ +validatePayload($request); + + try { + $payload = $this->interactions->toggleLike($request->user(), (string) $validated['content_type'], (int) $validated['content_id'], $request); + } catch (InvalidArgumentException $exception) { + return response()->json(['message' => $exception->getMessage()], 422); + } + + return response()->json($payload); + } + + public function save(Request $request): JsonResponse + { + abort_unless((bool) config('academy.enabled', true), 404); + + $validated = $this->validatePayload($request); + + try { + $payload = $this->interactions->toggleSave($request->user(), (string) $validated['content_type'], (int) $validated['content_id'], $request); + } catch (InvalidArgumentException $exception) { + return response()->json(['message' => $exception->getMessage()], 422); + } + + return response()->json($payload); + } + + /** + * @return array + */ + private function validatePayload(Request $request): array + { + return $request->validate([ + 'content_type' => ['required', 'string', Rule::in([ + AcademyAnalyticsContentType::PROMPT, + AcademyAnalyticsContentType::LESSON, + AcademyAnalyticsContentType::COURSE, + AcademyAnalyticsContentType::PROMPT_PACK, + AcademyAnalyticsContentType::CHALLENGE, + ])], + 'content_id' => ['required', 'integer', 'min:1'], + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Academy/AcademyLessonController.php b/app/Http/Controllers/Academy/AcademyLessonController.php index 1ecb40e0..1872745e 100644 --- a/app/Http/Controllers/Academy/AcademyLessonController.php +++ b/app/Http/Controllers/Academy/AcademyLessonController.php @@ -8,7 +8,10 @@ use App\Http\Controllers\Controller; use App\Models\AcademyCourse; use App\Models\AcademyLesson; use App\Services\Academy\AcademyAccessService; +use App\Services\Academy\AcademyAnalyticsService; use App\Services\Academy\AcademyCacheService; +use App\Services\Academy\AcademyInteractionService; +use App\Support\AcademyAnalytics\AcademyAnalyticsContentType; use App\Support\Seo\SeoFactory; use Illuminate\Http\Request; use Illuminate\Support\Str; @@ -20,6 +23,8 @@ final class AcademyLessonController extends Controller public function __construct( private readonly AcademyAccessService $access, private readonly AcademyCacheService $cache, + private readonly AcademyAnalyticsService $analytics, + private readonly AcademyInteractionService $interactions, ) {} public function index(Request $request): Response @@ -56,6 +61,10 @@ final class AcademyLessonController extends Controller $lessons = $query->paginate(12)->withQueryString(); $lessons->getCollection()->transform(fn (AcademyLesson $lesson): array => $this->access->lessonPayload($lesson, $request->user())); + if (filled($filters['q'] ?? null)) { + $this->analytics->trackSearch((string) $filters['q'], (int) $lessons->total(), array_filter($filters), $request); + } + $seo = app(SeoFactory::class) ->collectionListing( 'Academy Lessons — Skinbase', @@ -73,6 +82,20 @@ final class AcademyLessonController extends Controller 'filters' => $filters, 'categories' => $this->cache->categoriesByType('lesson'), 'pricingUrl' => route('academy.pricing'), + 'analytics' => [ + 'enabled' => true, + 'contentType' => filled($filters['q'] ?? null) ? AcademyAnalyticsContentType::SEARCH : null, + 'contentId' => null, + 'eventUrl' => route('academy.analytics.events.store'), + 'pageName' => 'academy_lessons_index', + 'search' => filled($filters['q'] ?? null) ? [ + 'query' => (string) $filters['q'], + 'resultsCount' => (int) $lessons->total(), + ] : null, + 'isPremium' => false, + 'isGuest' => $request->user() === null, + 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), + ], ])->rootView('collections'); } @@ -148,6 +171,8 @@ final class AcademyLessonController extends Controller (string) ($lesson->series_name ?: $lesson->category?->name ?: 'Academy'), )->toArray(); + $interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::LESSON, (int) $lesson->id); + return Inertia::render('Academy/Show', [ 'pageType' => 'lesson', 'item' => $payload, @@ -159,6 +184,26 @@ final class AcademyLessonController extends Controller 'pricingUrl' => route('academy.pricing'), 'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null, 'completed' => $request->user()?->academyLessonProgress()->where('lesson_id', $lesson->id)->whereNotNull('completed_at')->exists() ?? false, + 'interaction' => $interaction, + 'interactionRoutes' => [ + 'like' => route('academy.interactions.like'), + 'save' => route('academy.interactions.save'), + ], + 'loginUrl' => route('login'), + 'progressRoutes' => [ + 'startLesson' => $request->user() ? route('academy.progress.lesson.start') : null, + ], + 'analytics' => [ + 'enabled' => true, + 'contentType' => AcademyAnalyticsContentType::LESSON, + 'contentId' => (int) $lesson->id, + 'eventUrl' => route('academy.analytics.events.store'), + 'pageName' => 'academy_lesson_show', + 'isPremium' => (string) ($lesson->access_level ?? 'free') !== 'free', + 'isGuest' => $request->user() === null, + 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), + 'isLocked' => (bool) ($payload['locked'] ?? false), + ], ])->rootView('collections'); } } diff --git a/app/Http/Controllers/Academy/AcademyPricingController.php b/app/Http/Controllers/Academy/AcademyPricingController.php index 2ba26d1c..7353d39d 100644 --- a/app/Http/Controllers/Academy/AcademyPricingController.php +++ b/app/Http/Controllers/Academy/AcademyPricingController.php @@ -5,13 +5,15 @@ declare(strict_types=1); namespace App\Http\Controllers\Academy; use App\Http\Controllers\Controller; +use App\Support\AcademyAnalytics\AcademyAnalyticsContentType; use App\Support\Seo\SeoFactory; +use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; final class AcademyPricingController extends Controller { - public function index(): Response + public function index(Request $request): Response { abort_unless((bool) config('academy.enabled', true), 404); @@ -67,6 +69,16 @@ final class AcademyPricingController extends Controller ], ], ], + 'analytics' => [ + 'enabled' => true, + 'contentType' => AcademyAnalyticsContentType::UPGRADE, + 'contentId' => null, + 'eventUrl' => route('academy.analytics.events.store'), + 'pageName' => 'academy_pricing', + 'isPremium' => false, + 'isGuest' => $request->user() === null, + 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), + ], ])->rootView('collections'); } } \ No newline at end of file diff --git a/app/Http/Controllers/Academy/AcademyProgressController.php b/app/Http/Controllers/Academy/AcademyProgressController.php index c104dbf7..3d0fbccd 100644 --- a/app/Http/Controllers/Academy/AcademyProgressController.php +++ b/app/Http/Controllers/Academy/AcademyProgressController.php @@ -20,6 +20,32 @@ final class AcademyProgressController extends Controller ) { } + public function startLesson(Request $request): JsonResponse + { + abort_unless((bool) config('academy.enabled', true), 404); + + $validated = $request->validate([ + 'lesson_id' => ['required', 'integer', 'min:1'], + 'course_id' => ['nullable', 'integer', 'min:1'], + ]); + + $lesson = AcademyLesson::query()->findOrFail((int) $validated['lesson_id']); + abort_unless($this->access->canAccessLesson($request->user(), $lesson), 403); + + $courseId = $request->filled('course_id') ? (int) $validated['course_id'] : null; + if ($courseId !== null) { + $course = AcademyCourse::query()->published()->findOrFail($courseId); + abort_unless($course->courseLessons()->where('lesson_id', $lesson->id)->exists(), 403); + } + + $record = $this->progress->startLesson($request->user(), (int) $lesson->id, $courseId, $request); + + return response()->json([ + 'ok' => true, + 'status' => (string) $record->status, + ]); + } + public function complete(Request $request, AcademyLesson $lesson): JsonResponse { abort_unless((bool) config('academy.enabled', true), 404); @@ -31,7 +57,7 @@ final class AcademyProgressController extends Controller $course = AcademyCourse::query()->published()->find($request->integer('course_id')); } - $record = $this->progress->markLessonComplete($request->user(), $lesson, $course); + $record = $this->progress->markLessonComplete($request->user(), $lesson, $course, $request); return response()->json([ 'ok' => true, @@ -39,4 +65,55 @@ final class AcademyProgressController extends Controller 'completed_at' => $record->completed_at?->toISOString(), ]); } + + public function completeLesson(Request $request): JsonResponse + { + abort_unless((bool) config('academy.enabled', true), 404); + + $validated = $request->validate([ + 'lesson_id' => ['required', 'integer', 'min:1'], + 'course_id' => ['nullable', 'integer', 'min:1'], + ]); + + $lesson = AcademyLesson::query()->findOrFail((int) $validated['lesson_id']); + + return $this->complete($request, $lesson); + } + + public function startCourse(Request $request): JsonResponse + { + abort_unless((bool) config('academy.enabled', true), 404); + + $validated = $request->validate([ + 'course_id' => ['required', 'integer', 'min:1'], + ]); + + $course = AcademyCourse::query()->published()->findOrFail((int) $validated['course_id']); + $record = $this->progress->startCourse($request->user(), (int) $course->id, $request); + + return response()->json([ + 'ok' => true, + 'status' => (string) $record->status, + 'progress_percent' => (int) $record->progress_percent, + ]); + } + + public function completeCourse(Request $request): JsonResponse + { + abort_unless((bool) config('academy.enabled', true), 404); + + $validated = $request->validate([ + 'course_id' => ['required', 'integer', 'min:1'], + ]); + + $course = AcademyCourse::query()->published()->findOrFail((int) $validated['course_id']); + $record = $this->progress->completeCourse($request->user(), (int) $course->id, $request); + + return response()->json([ + 'ok' => true, + 'status' => (string) $record->status, + 'progress_percent' => (int) $record->progress_percent, + 'completed' => (string) $record->status === 'completed', + ]); + } } \ No newline at end of file diff --git a/app/Http/Controllers/Academy/AcademyPromptController.php b/app/Http/Controllers/Academy/AcademyPromptController.php index f67b9a5f..c0862276 100644 --- a/app/Http/Controllers/Academy/AcademyPromptController.php +++ b/app/Http/Controllers/Academy/AcademyPromptController.php @@ -7,9 +7,13 @@ namespace App\Http\Controllers\Academy; use App\Http\Controllers\Controller; use App\Models\AcademyPromptTemplate; use App\Services\Academy\AcademyAccessService; +use App\Services\Academy\AcademyAnalyticsService; use App\Services\Academy\AcademyCacheService; +use App\Services\Academy\AcademyInteractionService; +use App\Support\AcademyAnalytics\AcademyAnalyticsContentType; use App\Support\Seo\SeoFactory; use Illuminate\Http\Request; +use Illuminate\Http\JsonResponse; use Illuminate\Support\Str; use Inertia\Inertia; use Inertia\Response; @@ -19,10 +23,12 @@ final class AcademyPromptController extends Controller public function __construct( private readonly AcademyAccessService $access, private readonly AcademyCacheService $cache, + private readonly AcademyAnalyticsService $analytics, + private readonly AcademyInteractionService $interactions, ) { } - public function index(Request $request): Response + public function index(Request $request): Response|JsonResponse { abort_unless((bool) config('academy.enabled', true), 404); @@ -62,6 +68,14 @@ final class AcademyPromptController extends Controller $prompts = $query->paginate(12)->withQueryString(); $prompts->getCollection()->transform(fn (AcademyPromptTemplate $prompt): array => $this->access->promptPayload($prompt, $request->user())); + if (filled($filters['q'] ?? null)) { + $this->analytics->trackSearch((string) $filters['q'], (int) $prompts->total(), array_filter($filters), $request); + } + + if ($request->expectsJson()) { + return response()->json($prompts); + } + $seo = app(SeoFactory::class) ->collectionListing( 'Academy Prompts — Skinbase', @@ -79,6 +93,20 @@ final class AcademyPromptController extends Controller 'filters' => $filters, 'categories' => $this->cache->categoriesByType('prompt'), 'pricingUrl' => route('academy.pricing'), + 'analytics' => [ + 'enabled' => true, + 'contentType' => filled($filters['q'] ?? null) ? AcademyAnalyticsContentType::SEARCH : null, + 'contentId' => null, + 'eventUrl' => route('academy.analytics.events.store'), + 'pageName' => 'academy_prompts_index', + 'search' => filled($filters['q'] ?? null) ? [ + 'query' => (string) $filters['q'], + 'resultsCount' => (int) $prompts->total(), + ] : null, + 'isPremium' => false, + 'isGuest' => $request->user() === null, + 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), + ], ])->rootView('collections'); } @@ -102,15 +130,75 @@ final class AcademyPromptController extends Controller $canonical, $payload['preview_image'] ?? null, )->toArray(); + $existingSchemas = $seo['json_ld'] ?? []; + if (! is_array($existingSchemas) || ! array_is_list($existingSchemas)) { + $existingSchemas = [$existingSchemas]; + } + $seo['json_ld'] = [ + ...$existingSchemas, + $this->promptStructuredData($payload, $canonical, $description), + ]; + + $canSavePrompt = $request->user() !== null && $this->access->canAccessPrompt($request->user(), $prompt); + $interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::PROMPT, (int) $prompt->id); return Inertia::render('Academy/Show', [ 'pageType' => 'prompt', 'item' => $payload, 'seo' => $seo, 'pricingUrl' => route('academy.pricing'), - 'saveUrl' => $request->user() ? route('academy.prompts.save', ['prompt' => $prompt->id]) : null, - 'unsaveUrl' => $request->user() ? route('academy.prompts.unsave', ['prompt' => $prompt->id]) : null, - 'saved' => $request->user()?->academySavedPrompts()->where('prompt_template_id', $prompt->id)->exists() ?? false, + 'saveUrl' => $canSavePrompt ? route('academy.prompts.save', ['prompt' => $prompt->id]) : null, + 'unsaveUrl' => $canSavePrompt ? route('academy.prompts.unsave', ['prompt' => $prompt->id]) : null, + 'saved' => $canSavePrompt ? ($request->user()?->academySavedPrompts()->where('prompt_template_id', $prompt->id)->exists() ?? false) : false, + 'interaction' => $interaction, + 'interactionRoutes' => [ + 'like' => route('academy.interactions.like'), + 'save' => route('academy.interactions.save'), + ], + 'loginUrl' => route('login'), + 'analytics' => [ + 'enabled' => true, + 'contentType' => AcademyAnalyticsContentType::PROMPT, + 'contentId' => (int) $prompt->id, + 'eventUrl' => route('academy.analytics.events.store'), + 'pageName' => 'academy_prompt_show', + 'isPremium' => (string) ($prompt->access_level ?? 'free') !== 'free', + 'isGuest' => $request->user() === null, + 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), + 'isLocked' => (bool) ($payload['locked'] ?? false), + ], ])->rootView('collections'); } + + /** + * @param array $payload + * @return array + */ + private function promptStructuredData(array $payload, string $canonical, string $description): array + { + $imageUrls = array_values(array_unique(array_filter([ + $payload['preview_image'] ?? null, + ...collect((array) ($payload['public_examples'] ?? [])) + ->map(fn (array $example): ?string => $example['image_url'] ?? $example['thumb_url'] ?? null) + ->filter() + ->values() + ->all(), + ], fn (mixed $value): bool => is_string($value) && $value !== ''))); + $isFree = (string) ($payload['access_level'] ?? 'free') === 'free'; + + return array_filter([ + '@context' => 'https://schema.org', + '@type' => ['CreativeWork', 'LearningResource'], + 'name' => (string) ($payload['title'] ?? 'Skinbase Academy prompt'), + 'description' => $description, + 'url' => $canonical, + 'image' => $imageUrls !== [] ? $imageUrls : null, + 'isAccessibleForFree' => $isFree, + 'hasPart' => $isFree ? null : [ + '@type' => 'WebPageElement', + 'isAccessibleForFree' => false, + 'cssSelector' => '.academy-paywalled-content', + ], + ], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []); + } } \ No newline at end of file diff --git a/app/Http/Controllers/Academy/AcademyPromptPackController.php b/app/Http/Controllers/Academy/AcademyPromptPackController.php index 4fec5fd3..8a2e8bef 100644 --- a/app/Http/Controllers/Academy/AcademyPromptPackController.php +++ b/app/Http/Controllers/Academy/AcademyPromptPackController.php @@ -7,6 +7,8 @@ namespace App\Http\Controllers\Academy; use App\Http\Controllers\Controller; use App\Models\AcademyPromptPack; use App\Services\Academy\AcademyAccessService; +use App\Services\Academy\AcademyInteractionService; +use App\Support\AcademyAnalytics\AcademyAnalyticsContentType; use App\Support\Seo\SeoFactory; use Illuminate\Http\Request; use Illuminate\Support\Str; @@ -15,7 +17,10 @@ use Inertia\Response; final class AcademyPromptPackController extends Controller { - public function __construct(private readonly AcademyAccessService $access) + public function __construct( + private readonly AcademyAccessService $access, + private readonly AcademyInteractionService $interactions, + ) { } @@ -50,6 +55,16 @@ final class AcademyPromptPackController extends Controller 'filters' => [], 'categories' => [], 'pricingUrl' => route('academy.pricing'), + 'analytics' => [ + 'enabled' => true, + 'contentType' => null, + 'contentId' => null, + 'eventUrl' => route('academy.analytics.events.store'), + 'pageName' => 'academy_packs_index', + 'isPremium' => false, + 'isGuest' => $request->user() === null, + 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), + ], ])->rootView('collections'); } @@ -72,11 +87,30 @@ final class AcademyPromptPackController extends Controller $pack->cover_image, )->toArray(); + $interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::PROMPT_PACK, (int) $pack->id); + return Inertia::render('Academy/Show', [ 'pageType' => 'pack', 'item' => $payload, 'seo' => $seo, 'pricingUrl' => route('academy.pricing'), + 'interaction' => $interaction, + 'interactionRoutes' => [ + 'like' => route('academy.interactions.like'), + 'save' => route('academy.interactions.save'), + ], + 'loginUrl' => route('login'), + 'analytics' => [ + 'enabled' => true, + 'contentType' => AcademyAnalyticsContentType::PROMPT_PACK, + 'contentId' => (int) $pack->id, + 'eventUrl' => route('academy.analytics.events.store'), + 'pageName' => 'academy_pack_show', + 'isPremium' => (string) ($pack->access_level ?? 'free') !== 'free', + 'isGuest' => $request->user() === null, + 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), + 'isLocked' => (bool) ($payload['locked'] ?? false), + ], ])->rootView('collections'); } } \ No newline at end of file diff --git a/app/Http/Controllers/Api/LatestCommentsApiController.php b/app/Http/Controllers/Api/LatestCommentsApiController.php index dcc7656f..40225afb 100644 --- a/app/Http/Controllers/Api/LatestCommentsApiController.php +++ b/app/Http/Controllers/Api/LatestCommentsApiController.php @@ -11,11 +11,21 @@ use App\Services\ThumbnailPresenter; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; use Carbon\Carbon; +use Illuminate\Contracts\Pagination\Paginator; class LatestCommentsApiController extends Controller { private const PER_PAGE = 20; + private function paginationMeta(Paginator $paginator): array + { + return [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'has_more' => $paginator->hasMorePages(), + ]; + } + public function index(Request $request): JsonResponse { $type = $request->query('type', 'all'); @@ -66,15 +76,21 @@ class LatestCommentsApiController extends Controller $cacheKey = 'comments.latest.all.page1'; $ttl = 120; // 2 minutes - $paginator = Cache::remember($cacheKey, $ttl, fn () => $query->paginate(self::PER_PAGE)); + $paginator = Cache::remember($cacheKey, $ttl, fn () => $query + ->orderByDesc('artwork_comments.id') + ->simplePaginate(self::PER_PAGE)); } else { - $paginator = $query->paginate(self::PER_PAGE); + $paginator = $query + ->orderByDesc('artwork_comments.id') + ->simplePaginate(self::PER_PAGE); } break; } if (! isset($paginator)) { - $paginator = $query->paginate(self::PER_PAGE); + $paginator = $query + ->orderByDesc('artwork_comments.id') + ->simplePaginate(self::PER_PAGE); } $items = $paginator->getCollection()->map(function (ArtworkComment $c) { @@ -113,13 +129,7 @@ class LatestCommentsApiController extends Controller return response()->json([ 'data' => $items, - 'meta' => [ - 'current_page' => $paginator->currentPage(), - 'last_page' => $paginator->lastPage(), - 'per_page' => $paginator->perPage(), - 'total' => $paginator->total(), - 'has_more' => $paginator->hasMorePages(), - ], + 'meta' => $this->paginationMeta($paginator), ]); } } diff --git a/app/Http/Controllers/Community/LatestCommentsController.php b/app/Http/Controllers/Community/LatestCommentsController.php index de7b276a..f4796a1a 100644 --- a/app/Http/Controllers/Community/LatestCommentsController.php +++ b/app/Http/Controllers/Community/LatestCommentsController.php @@ -10,11 +10,21 @@ use App\Services\ThumbnailPresenter; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; use Carbon\Carbon; +use Illuminate\Contracts\Pagination\Paginator; class LatestCommentsController extends Controller { private const PER_PAGE = 20; + private function paginationMeta(Paginator $paginator): array + { + return [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'has_more' => $paginator->hasMorePages(), + ]; + } + public function index(Request $request) { $page_title = 'Latest Comments'; @@ -38,7 +48,8 @@ class LatestCommentsController extends Controller $q->public()->published()->whereNull('deleted_at'); }) ->orderByDesc('artwork_comments.created_at') - ->paginate(self::PER_PAGE); + ->orderByDesc('artwork_comments.id') + ->simplePaginate(self::PER_PAGE); }); $items = $initialData->getCollection()->map(function (ArtworkComment $c) { @@ -76,13 +87,7 @@ class LatestCommentsController extends Controller $props = [ 'initialComments' => $items->values()->all(), - 'initialMeta' => [ - 'current_page' => $initialData->currentPage(), - 'last_page' => $initialData->lastPage(), - 'per_page' => $initialData->perPage(), - 'total' => $initialData->total(), - 'has_more' => $initialData->hasMorePages(), - ], + 'initialMeta' => $this->paginationMeta($initialData), 'isAuthenticated' => (bool) auth()->user(), ]; diff --git a/app/Http/Controllers/Settings/AcademyAdminAnalyticsController.php b/app/Http/Controllers/Settings/AcademyAdminAnalyticsController.php new file mode 100644 index 00000000..470283e1 --- /dev/null +++ b/app/Http/Controllers/Settings/AcademyAdminAnalyticsController.php @@ -0,0 +1,470 @@ +resolveDateRange($request); + + $summary = $this->metricsQuery($from, $to) + ->selectRaw('sum(views) as views, sum(unique_visitors) as unique_visitors, sum(user_views) as user_views, sum(guest_views) as guest_views, sum(subscriber_views) as subscriber_views, sum(prompt_copies) as prompt_copies, sum(likes) as likes, sum(saves) as saves, sum(completions) as completions, sum(starts) as starts, sum(upgrade_clicks) as upgrade_clicks') + ->first(); + + return Inertia::render('Admin/Academy/AnalyticsOverview', [ + 'nav' => $this->nav(), + 'range' => $this->rangePayload($range, $from, $to), + 'stats' => [ + 'views' => (int) ($summary?->views ?? 0), + 'uniqueVisitors' => (int) ($summary?->unique_visitors ?? 0), + 'userViews' => (int) ($summary?->user_views ?? 0), + 'guestViews' => (int) ($summary?->guest_views ?? 0), + 'subscriberViews' => (int) ($summary?->subscriber_views ?? 0), + 'promptCopies' => (int) ($summary?->prompt_copies ?? 0), + 'likes' => (int) ($summary?->likes ?? 0), + 'saves' => (int) ($summary?->saves ?? 0), + 'lessonCompletions' => (int) ($summary?->completions ?? 0), + 'courseStarts' => (int) ($summary?->starts ?? 0), + 'upgradeClicks' => (int) ($summary?->upgrade_clicks ?? 0), + ], + 'topContent' => $this->serializeContentRows($this->popularity->topContent($from, $to, 8)), + 'topWeek' => $this->serializeContentRows($this->popularity->topContent(now()->subDays(6)->startOfDay(), now()->endOfDay(), 8)), + ]); + } + + public function content(Request $request): Response + { + return $this->renderContentPage($request, null, 'Content performance', 'Cross-module performance across prompts, lessons, courses, packs, and challenges.'); + } + + public function prompts(Request $request): Response + { + return $this->renderContentPage($request, AcademyAnalyticsContentType::PROMPT, 'Prompt analytics', 'Copy-heavy prompt performance, save rates, and upgrade interest.'); + } + + public function lessons(Request $request): Response + { + return $this->renderContentPage($request, AcademyAnalyticsContentType::LESSON, 'Lesson analytics', 'Lesson engagement, starts, completions, and drop-off signals.'); + } + + public function courses(Request $request): Response + { + return $this->renderContentPage($request, AcademyAnalyticsContentType::COURSE, 'Course analytics', 'Course views, starts, completion progress, and upgrade intent.'); + } + + public function search(Request $request): Response + { + [$from, $to, $range] = $this->resolveDateRange($request); + + $searchQuery = AcademySearchLog::query()->whereBetween('created_at', [$from, $to]); + $searchLogs = (clone $searchQuery)->latest('created_at')->limit(500)->get(); + + return Inertia::render('Admin/Academy/AnalyticsSearch', [ + 'nav' => $this->nav(), + 'range' => $this->rangePayload($range, $from, $to), + 'summary' => [ + 'searches' => (int) (clone $searchQuery)->count(), + 'zeroResultSearches' => (int) (clone $searchQuery)->where('results_count', 0)->count(), + 'loggedInSearches' => (int) (clone $searchQuery)->where('is_logged_in', true)->count(), + 'subscriberSearches' => (int) (clone $searchQuery)->where('is_subscriber', true)->count(), + 'searchesWithClicks' => (int) (clone $searchQuery)->whereNotNull('clicked_content_id')->count(), + ], + 'topSearches' => (clone $searchQuery) + ->selectRaw('normalized_query, max(query) as query, count(*) as searches, sum(results_count = 0) as zero_result_hits, avg(results_count) as avg_results, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks') + ->groupBy('normalized_query') + ->orderByDesc('searches') + ->limit(20) + ->get() + ->map(fn ($row): array => [ + 'query' => (string) ($row->query ?: $row->normalized_query), + 'normalized_query' => (string) $row->normalized_query, + 'searches' => (int) $row->searches, + 'zero_result_hits' => (int) $row->zero_result_hits, + 'avg_results' => round((float) $row->avg_results, 1), + 'clicks' => (int) ($row->clicks ?? 0), + 'click_through_rate' => (int) $row->searches > 0 ? round((((int) ($row->clicks ?? 0)) / (int) $row->searches) * 100, 1) : 0, + ]) + ->all(), + 'zeroResults' => (clone $searchQuery) + ->selectRaw('normalized_query, max(query) as query, count(*) as searches') + ->where('results_count', 0) + ->groupBy('normalized_query') + ->orderByDesc('searches') + ->limit(20) + ->get() + ->map(fn ($row): array => [ + 'query' => (string) ($row->query ?: $row->normalized_query), + 'searches' => (int) $row->searches, + ]) + ->all(), + 'lowClickThroughSearches' => (clone $searchQuery) + ->selectRaw('normalized_query, max(query) as query, count(*) as searches, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks, avg(results_count) as avg_results') + ->groupBy('normalized_query') + ->havingRaw('count(*) >= 2') + ->orderByRaw('case when count(*) = 0 then 1 else (sum(case when clicked_content_id is not null then 1 else 0 end) * 1.0 / count(*)) end asc') + ->limit(20) + ->get() + ->map(fn ($row): array => [ + 'query' => (string) ($row->query ?: $row->normalized_query), + 'searches' => (int) $row->searches, + 'clicks' => (int) ($row->clicks ?? 0), + 'avg_results' => round((float) $row->avg_results, 1), + 'click_through_rate' => (int) $row->searches > 0 ? round((((int) ($row->clicks ?? 0)) / (int) $row->searches) * 100, 1) : 0, + ]) + ->all(), + 'highestClickThroughSearches' => (clone $searchQuery) + ->selectRaw('normalized_query, max(query) as query, count(*) as searches, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks, avg(results_count) as avg_results') + ->groupBy('normalized_query') + ->havingRaw('count(*) >= 2') + ->orderByRaw('(sum(case when clicked_content_id is not null then 1 else 0 end) * 1.0 / count(*)) desc') + ->limit(20) + ->get() + ->map(fn ($row): array => [ + 'query' => (string) ($row->query ?: $row->normalized_query), + 'searches' => (int) $row->searches, + 'clicks' => (int) ($row->clicks ?? 0), + 'avg_results' => round((float) $row->avg_results, 1), + 'click_through_rate' => (int) $row->searches > 0 ? round((((int) ($row->clicks ?? 0)) / (int) $row->searches) * 100, 1) : 0, + ]) + ->all(), + 'searchesWithResultsNoClicks' => (clone $searchQuery) + ->selectRaw('normalized_query, max(query) as query, count(*) as searches, avg(results_count) as avg_results') + ->where('results_count', '>', 0) + ->whereNull('clicked_content_id') + ->groupBy('normalized_query') + ->orderByDesc('searches') + ->limit(20) + ->get() + ->map(fn ($row): array => [ + 'query' => (string) ($row->query ?: $row->normalized_query), + 'searches' => (int) $row->searches, + 'avg_results' => round((float) $row->avg_results, 1), + 'clicks' => 0, + 'click_through_rate' => 0, + ]) + ->all(), + 'topClickedResults' => (clone $searchQuery) + ->selectRaw('clicked_content_type, clicked_content_id, count(*) as clicks') + ->whereNotNull('clicked_content_type') + ->whereNotNull('clicked_content_id') + ->groupBy('clicked_content_type', 'clicked_content_id') + ->orderByDesc('clicks') + ->limit(20) + ->get() + ->map(fn ($row): array => [ + 'title' => $this->resolver->title((string) $row->clicked_content_type, (int) $row->clicked_content_id), + 'content_type' => (string) $row->clicked_content_type, + 'content_id' => (int) $row->clicked_content_id, + 'clicks' => (int) $row->clicks, + ]) + ->all(), + 'filterUsage' => $this->summarizeSearchFilters($searchLogs), + 'recentSearches' => (clone $searchQuery) + ->latest('created_at') + ->limit(25) + ->get() + ->map(fn (AcademySearchLog $log): array => [ + 'query' => (string) $log->query, + 'results_count' => (int) $log->results_count, + 'logged_in' => (bool) $log->is_logged_in, + 'subscriber' => (bool) $log->is_subscriber, + 'clicked_content_type' => $log->clicked_content_type, + 'has_click' => $log->clicked_content_id !== null, + 'created_at' => $log->created_at?->toISOString(), + ]) + ->all(), + ]); + } + + public function intelligence(Request $request): Response + { + [$from, $to, $range] = $this->resolveDateRange($request, '30d'); + $filters = [ + 'from' => $from, + 'to' => $to, + 'limit' => 25, + ]; + + return Inertia::render('Admin/Academy/AnalyticsIntelligence', [ + 'nav' => $this->nav(), + 'range' => $this->rangePayload($range, $from, $to), + 'contentOpportunities' => $this->intelligence->getContentOpportunities($filters), + 'searchGaps' => $this->intelligence->getSearchGaps($filters), + 'promptInsights' => $this->intelligence->getPromptInsights($filters), + 'lessonDropoffs' => $this->intelligence->getLessonDropoffs($filters), + 'courseHealth' => $this->intelligence->getCourseHealth($filters), + 'premiumInterest' => $this->intelligence->getPremiumInterest($filters), + 'editorialRecommendations' => $this->intelligence->getEditorialRecommendations($filters), + ]); + } + + /** + * @param Collection $logs + * @return list> + */ + private function summarizeSearchFilters(Collection $logs): array + { + $counts = []; + + foreach ($logs as $log) { + $filters = is_array($log->filters) ? $log->filters : []; + + foreach ($filters as $key => $value) { + if ($value === null || $value === '' || $key === 'q') { + continue; + } + + $values = is_array($value) ? $value : [$value]; + + foreach ($values as $rawValue) { + $label = trim((string) $rawValue); + if ($label === '') { + continue; + } + + $bucket = sprintf('%s:%s', $key, $label); + $counts[$bucket] = [ + 'filter' => (string) $key, + 'value' => $label, + 'uses' => (int) (($counts[$bucket]['uses'] ?? 0) + 1), + ]; + } + } + } + + usort($counts, static fn (array $left, array $right): int => $right['uses'] <=> $left['uses']); + + return array_slice(array_values($counts), 0, 20); + } + + public function funnel(Request $request): Response + { + [$from, $to, $range] = $this->resolveDateRange($request); + + $summary = $this->metricsQuery($from, $to) + ->selectRaw('sum(unique_visitors) as unique_visitors, sum(premium_preview_views) as premium_preview_views, sum(upgrade_clicks) as upgrade_clicks, sum(starts) as starts, sum(completions) as completions') + ->first(); + + $bestConverters = $this->metricsQuery($from, $to) + ->selectRaw('content_type, content_id, sum(unique_visitors) as unique_visitors, sum(premium_preview_views) as premium_preview_views, sum(upgrade_clicks) as upgrade_clicks, sum(conversion_score) as conversion_score') + ->groupBy('content_type', 'content_id') + ->havingRaw('sum(upgrade_clicks) > 0') + ->orderByDesc('conversion_score') + ->limit(12) + ->get(); + + return Inertia::render('Admin/Academy/AnalyticsFunnel', [ + 'nav' => $this->nav(), + 'range' => $this->rangePayload($range, $from, $to), + 'summary' => [ + 'academyVisitors' => (int) ($summary?->unique_visitors ?? 0), + 'premiumPreviewViews' => (int) ($summary?->premium_preview_views ?? 0), + 'upgradeClicks' => (int) ($summary?->upgrade_clicks ?? 0), + 'starts' => (int) ($summary?->starts ?? 0), + 'completions' => (int) ($summary?->completions ?? 0), + 'checkoutStarts' => 0, + 'subscriptions' => 0, + ], + 'bestConverters' => $this->serializeContentRows($bestConverters, includeConversion: true), + ]); + } + + private function renderContentPage(Request $request, ?string $forcedContentType, string $title, string $subtitle): Response + { + [$from, $to, $range] = $this->resolveDateRange($request); + $sort = (string) $request->query('sort', 'popularity_score'); + $direction = strtolower((string) $request->query('direction', 'desc')) === 'asc' ? 'asc' : 'desc'; + $access = trim((string) $request->query('access', '')); + $contentType = $forcedContentType ?: (trim((string) $request->query('content_type', '')) ?: null); + + $query = $this->metricsQuery($from, $to) + ->selectRaw('content_type, content_id, sum(views) as views, sum(unique_visitors) as unique_visitors, sum(engaged_views) as engaged_views, sum(likes) as likes, sum(saves) as saves, sum(prompt_copies) as prompt_copies, sum(starts) as starts, sum(completions) as completions, sum(upgrade_clicks) as upgrade_clicks, sum(popularity_score) as popularity_score, sum(conversion_score) as conversion_score') + ->groupBy('content_type', 'content_id'); + + if ($contentType !== null) { + $query->where('content_type', $contentType); + } + + $rows = $query->get(); + + $serializedRows = $this->serializeContentRows($rows, includeConversion: true) + ->filter(function (array $row) use ($access): bool { + if ($access === '') { + return true; + } + + return strtolower((string) ($row['access_level'] ?? '')) === strtolower($access); + }) + ->sortBy($sort, SORT_REGULAR, $direction === 'desc') + ->values() + ->all(); + + return Inertia::render('Admin/Academy/AnalyticsContent', [ + 'nav' => $this->nav(), + 'range' => $this->rangePayload($range, $from, $to), + 'title' => $title, + 'subtitle' => $subtitle, + 'filters' => [ + 'sort' => $sort, + 'direction' => $direction, + 'access' => $access, + 'content_type' => $contentType, + ], + 'rows' => $serializedRows, + 'contentTypeOptions' => [ + ['value' => '', 'label' => 'All content'], + ['value' => AcademyAnalyticsContentType::PROMPT, 'label' => 'Prompts'], + ['value' => AcademyAnalyticsContentType::LESSON, 'label' => 'Lessons'], + ['value' => AcademyAnalyticsContentType::COURSE, 'label' => 'Courses'], + ['value' => AcademyAnalyticsContentType::PROMPT_PACK, 'label' => 'Prompt packs'], + ['value' => AcademyAnalyticsContentType::CHALLENGE, 'label' => 'Challenges'], + ], + 'sortOptions' => [ + ['value' => 'views', 'label' => 'Views'], + ['value' => 'unique_visitors', 'label' => 'Unique visitors'], + ['value' => 'likes', 'label' => 'Likes'], + ['value' => 'saves', 'label' => 'Saves'], + ['value' => 'prompt_copies', 'label' => 'Copies'], + ['value' => 'completions', 'label' => 'Completions'], + ['value' => 'upgrade_clicks', 'label' => 'Upgrade clicks'], + ['value' => 'popularity_score', 'label' => 'Popularity score'], + ['value' => 'conversion_score', 'label' => 'Conversion score'], + ], + ]); + } + + private function metricsQuery(Carbon $from, Carbon $to) + { + return AcademyContentMetricDaily::query() + ->whereBetween('date', [$from->toDateString(), $to->toDateString()]); + } + + /** + * @param Collection $rows + * @return Collection> + */ + private function serializeContentRows(Collection $rows, bool $includeConversion = false): Collection + { + return $rows->map(function ($row) use ($includeConversion): array { + $contentType = (string) $row->content_type; + $contentId = $row->content_id ? (int) $row->content_id : null; + $title = $this->resolver->title($contentType, $contentId); + $accessLevel = $this->resolver->accessLevel($contentType, $contentId); + $uniqueVisitors = max(0, (int) ($row->unique_visitors ?? 0)); + $promptCopies = max(0, (int) ($row->prompt_copies ?? 0)); + $likes = max(0, (int) ($row->likes ?? 0)); + $saves = max(0, (int) ($row->saves ?? 0)); + $starts = max(0, (int) ($row->starts ?? 0)); + $completions = max(0, (int) ($row->completions ?? 0)); + $premiumPreviewViews = max(0, (int) ($row->premium_preview_views ?? 0)); + $upgradeClicks = max(0, (int) ($row->upgrade_clicks ?? 0)); + + return [ + 'content_type' => $contentType, + 'content_type_label' => (string) Str::of(str_replace('academy_', '', $contentType))->replace('_', ' ')->headline(), + 'content_id' => $contentId, + 'title' => $title, + 'access_level' => $accessLevel, + 'views' => (int) ($row->views ?? 0), + 'unique_visitors' => $uniqueVisitors, + 'engaged_views' => (int) ($row->engaged_views ?? 0), + 'likes' => $likes, + 'saves' => $saves, + 'prompt_copies' => $promptCopies, + 'starts' => $starts, + 'completions' => $completions, + 'upgrade_clicks' => $upgradeClicks, + 'popularity_score' => round((float) ($row->popularity_score ?? 0), 2), + 'conversion_score' => round((float) ($row->conversion_score ?? 0), 2), + 'copy_rate' => $uniqueVisitors > 0 ? round(($promptCopies / $uniqueVisitors) * 100, 1) : 0, + 'save_rate' => $uniqueVisitors > 0 ? round(($saves / $uniqueVisitors) * 100, 1) : 0, + 'like_rate' => $uniqueVisitors > 0 ? round(($likes / $uniqueVisitors) * 100, 1) : 0, + 'completion_rate' => $starts > 0 ? round(($completions / $starts) * 100, 1) : 0, + 'upgrade_rate' => max(1, $premiumPreviewViews) > 0 ? round(($upgradeClicks / max(1, $premiumPreviewViews)) * 100, 1) : 0, + 'trend' => ((float) ($row->popularity_score ?? 0)) >= 100 ? 'High momentum' : (((float) ($row->popularity_score ?? 0)) >= 25 ? 'Building' : 'Early'), + 'include_conversion' => $includeConversion, + ]; + }); + } + + /** + * @return array{0: Carbon, 1: Carbon, 2: string} + */ + private function resolveDateRange(Request $request, string $defaultRange = '7d'): array + { + $range = trim((string) $request->query('range', $defaultRange)); + + return match ($range) { + 'today' => [now()->startOfDay(), now()->endOfDay(), 'today'], + 'yesterday' => [now()->subDay()->startOfDay(), now()->subDay()->endOfDay(), 'yesterday'], + '30d' => [now()->subDays(29)->startOfDay(), now()->endOfDay(), '30d'], + '90d' => [now()->subDays(89)->startOfDay(), now()->endOfDay(), '90d'], + 'custom' => [ + Carbon::parse((string) $request->query('from', now()->subDays(6)->toDateString()))->startOfDay(), + Carbon::parse((string) $request->query('to', now()->toDateString()))->endOfDay(), + 'custom', + ], + default => [now()->subDays(6)->startOfDay(), now()->endOfDay(), '7d'], + }; + } + + /** + * @return list> + */ + private function nav(): array + { + return [ + ['label' => 'Overview', 'href' => route('admin.academy.analytics.overview')], + ['label' => 'Intelligence', 'href' => route('admin.academy.analytics.intelligence')], + ['label' => 'Content', 'href' => route('admin.academy.analytics.content')], + ['label' => 'Prompts', 'href' => route('admin.academy.analytics.prompts')], + ['label' => 'Lessons', 'href' => route('admin.academy.analytics.lessons')], + ['label' => 'Courses', 'href' => route('admin.academy.analytics.courses')], + ['label' => 'Search', 'href' => route('admin.academy.analytics.search')], + ['label' => 'Funnel', 'href' => route('admin.academy.analytics.funnel')], + ]; + } + + /** + * @return array + */ + private function rangePayload(string $activeRange, Carbon $from, Carbon $to): array + { + return [ + 'active' => $activeRange, + 'from' => $from->toDateString(), + 'to' => $to->toDateString(), + 'options' => [ + ['value' => 'today', 'label' => 'Today'], + ['value' => 'yesterday', 'label' => 'Yesterday'], + ['value' => '7d', 'label' => 'Last 7 days'], + ['value' => '30d', 'label' => 'Last 30 days'], + ['value' => '90d', 'label' => 'Last 90 days'], + ['value' => 'custom', 'label' => 'Custom range'], + ], + ]; + } +} diff --git a/app/Http/Controllers/Settings/AcademyAdminController.php b/app/Http/Controllers/Settings/AcademyAdminController.php index 2e5e1de5..1d007e96 100644 --- a/app/Http/Controllers/Settings/AcademyAdminController.php +++ b/app/Http/Controllers/Settings/AcademyAdminController.php @@ -19,6 +19,7 @@ use App\Models\AcademyChallenge; use App\Models\AcademyChallengeSubmission; use App\Models\AcademyCourse; use App\Models\AcademyCourseLesson; +use App\Models\AcademyCourseSection; use App\Models\AcademyLesson; use App\Models\AcademyLessonBlock; use App\Models\AcademyLessonRevision; @@ -26,6 +27,7 @@ use App\Models\AcademyPromptPack; use App\Models\AcademyPromptPackItem; use App\Models\AcademyPromptTemplate; use App\Models\User; +use App\Services\Academy\AcademyAdminBillingOverviewService; use App\Services\Academy\AcademyCacheService; use App\Services\Academy\AcademyCourseLessonOrderingService; use App\Services\Academy\AcademyLessonMarkdownRenderer; @@ -38,6 +40,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; use Inertia\Inertia; use Inertia\Response; @@ -48,7 +51,13 @@ final class AcademyAdminController extends Controller private const PROMPT_PREVIEW_PREFIX = 'academy-prompts/previews'; + private const PROMPT_PREVIEW_VARIANT_WIDTHS = [ + 'thumb' => 480, + 'md' => 960, + ]; + public function __construct( + private readonly AcademyAdminBillingOverviewService $billingOverview, private readonly AcademyCacheService $cache, private readonly AcademyCourseLessonOrderingService $courseLessonOrdering, private readonly AcademyLessonMarkdownRenderer $lessonMarkdownRenderer, @@ -56,6 +65,8 @@ final class AcademyAdminController extends Controller public function dashboard(): Response { + $billingSummary = $this->billingOverview->summary(); + return Inertia::render('Admin/Academy/Dashboard', [ 'stats' => [ 'courses' => AcademyCourse::query()->count(), @@ -65,11 +76,13 @@ final class AcademyAdminController extends Controller 'challenges' => AcademyChallenge::query()->count(), 'submissions' => AcademyChallengeSubmission::query()->count(), 'badges' => AcademyBadge::query()->count(), - 'creator_subscribers' => 0, - 'pro_subscribers' => 0, - 'mrr' => 0, + 'active_subscribers' => (int) ($billingSummary['active_subscribers'] ?? 0), + 'creator_subscribers' => (int) ($billingSummary['creator_subscribers'] ?? 0), + 'pro_subscribers' => (int) ($billingSummary['pro_subscribers'] ?? 0), + 'grace_period_subscribers' => (int) ($billingSummary['grace_period_subscribers'] ?? 0), ], 'links' => [ + 'billing' => route('admin.academy.billing'), 'courses' => route('admin.academy.courses.index'), 'categories' => route('admin.academy.categories.index'), 'lessons' => route('admin.academy.lessons.index'), @@ -83,6 +96,22 @@ final class AcademyAdminController extends Controller ]); } + public function billing(): Response + { + $summary = $this->billingOverview->summary(); + + return Inertia::render('Admin/Academy/Billing', [ + 'summary' => $summary, + 'planBreakdown' => $summary['plan_breakdown'] ?? [], + 'recentEvents' => $this->billingOverview->recentEvents(), + 'links' => [ + 'dashboard' => route('admin.academy.dashboard'), + 'pricing' => route('academy.pricing'), + 'account' => route('academy.billing.account'), + ], + ]); + } + public function categoriesIndex(): Response { return $this->renderIndex('categories'); @@ -100,13 +129,20 @@ final class AcademyAdminController extends Controller public function coursesStore(UpsertAcademyCourseRequest $request): RedirectResponse { - $course = new AcademyCourse; - $course->fill($this->persistCourseAttributes($request))->save(); + $course = $this->saveCourseFromRequest($request); $this->cache->clearAll(); return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $course])->with('success', 'Academy course created.'); } + public function coursesStoreJson(UpsertAcademyCourseRequest $request): RedirectResponse + { + $course = $this->saveCourseFromRequest($request); + $this->cache->clearAll(); + + return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $course])->with('success', 'Academy course created from JSON.'); + } + public function coursesEdit(AcademyCourse $academyCourse): Response { return $this->renderForm('courses', $academyCourse); @@ -114,12 +150,94 @@ final class AcademyAdminController extends Controller public function coursesUpdate(UpsertAcademyCourseRequest $request, AcademyCourse $academyCourse): RedirectResponse { - $academyCourse->fill($this->persistCourseAttributes($request, $academyCourse))->save(); + $this->saveCourseFromRequest($request, $academyCourse); $this->cache->clearAll(); return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $academyCourse])->with('success', 'Academy course updated.'); } + public function coursesImportLessons(Request $request, AcademyCourse $academyCourse): RedirectResponse + { + $difficultyLevels = array_values(array_filter(array_map('strval', (array) config('academy.difficulty_levels', [])))); + + $validated = $request->validate([ + 'defaults' => ['nullable', 'array'], + 'defaults.category_id' => ['nullable', 'integer', 'exists:academy_categories,id'], + 'defaults.category_slug' => ['nullable', 'string', 'max:180'], + 'defaults.category' => ['nullable', 'string', 'max:180'], + 'defaults.difficulty' => ['nullable', 'string', Rule::in($difficultyLevels)], + 'defaults.access_level' => ['nullable', 'string', Rule::in(['free', 'creator', 'pro'])], + 'defaults.lesson_type' => ['nullable', 'string', 'max:80'], + 'defaults.active' => ['nullable', 'boolean'], + 'defaults.series_name' => ['nullable', 'string', 'max:120'], + 'lessons' => ['required', 'array', 'min:1', 'max:250'], + 'lessons.*.title' => ['required', 'string', 'max:180'], + 'lessons.*.slug' => ['nullable', 'string', 'max:180'], + 'lessons.*.goal' => ['nullable', 'string'], + 'lessons.*.excerpt' => ['nullable', 'string'], + 'lessons.*.category_id' => ['nullable', 'integer', 'exists:academy_categories,id'], + 'lessons.*.category_slug' => ['nullable', 'string', 'max:180'], + 'lessons.*.category' => ['nullable', 'string', 'max:180'], + 'lessons.*.difficulty' => ['nullable', 'string', Rule::in($difficultyLevels)], + 'lessons.*.access_level' => ['nullable', 'string', Rule::in(['free', 'creator', 'pro'])], + 'lessons.*.lesson_type' => ['nullable', 'string', 'max:80'], + 'lessons.*.active' => ['nullable', 'boolean'], + 'lessons.*.series_name' => ['nullable', 'string', 'max:120'], + 'lessons.*.tags' => ['nullable', 'array'], + 'lessons.*.tags.*' => ['string', 'max:60'], + ]); + + $defaults = (array) ($validated['defaults'] ?? []); + $lessons = array_values((array) ($validated['lessons'] ?? [])); + + if ($lessons === []) { + throw ValidationException::withMessages([ + 'lessons' => 'Provide at least one lesson row to import.', + ]); + } + + DB::transaction(function () use ($academyCourse, $defaults, $lessons): void { + $reservedSlugs = AcademyLesson::query() + ->pluck('slug') + ->filter(fn ($slug): bool => is_string($slug) && trim($slug) !== '') + ->map(fn ($slug): string => trim((string) $slug)) + ->values() + ->all(); + + $nextOrder = (int) ((AcademyCourseLesson::query()->where('course_id', $academyCourse->id)->max('order_num') ?? -1) + 1); + + foreach ($lessons as $lessonData) { + $attributes = $this->buildImportedCourseLessonAttributes($academyCourse, (array) $lessonData, $defaults, $reservedSlugs); + + $lesson = new AcademyLesson; + $lesson->fill($attributes)->save(); + + AcademyCourseLesson::query()->create([ + 'course_id' => $academyCourse->id, + 'lesson_id' => $lesson->id, + 'section_id' => null, + 'order_num' => $nextOrder, + 'is_required' => true, + 'access_override' => null, + 'unlock_after_lesson_id' => null, + ]); + + $nextOrder++; + } + + $this->courseLessonOrdering->syncCourse($academyCourse); + + $academyCourse->forceFill([ + 'lessons_count_cache' => (int) AcademyCourseLesson::query()->where('course_id', $academyCourse->id)->count(), + ])->save(); + }); + + $this->cache->clearAll(); + + return redirect()->route('admin.academy.courses.edit', ['academyCourse' => $academyCourse]) + ->with('success', sprintf('%d lesson%s imported into the course.', count($lessons), count($lessons) === 1 ? '' : 's')); + } + public function coursesDestroy(AcademyCourse $academyCourse): RedirectResponse { $this->deleteStoredLessonCoverIfLocal((string) $academyCourse->cover_image); @@ -484,12 +602,40 @@ final class AcademyAdminController extends Controller private function renderIndex(string $resource): Response { $meta = $this->resourceMeta($resource); - $query = $meta['model']::query()->latest('updated_at'); + $search = trim((string) request()->query('search', '')); + $query = $meta['model']::query(); + + if ($resource === 'courses') { + $query->withCount('courseLessons'); + + if ($search !== '') { + $query->where(function ($builder) use ($search): void { + $like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%'; + + $builder->where('title', 'like', $like) + ->orWhere('slug', 'like', $like) + ->orWhere('subtitle', 'like', $like) + ->orWhere('excerpt', 'like', $like) + ->orWhere('description', 'like', $like); + }); + } + + $query->orderByDesc('is_featured') + ->orderBy('order_num') + ->orderByDesc('updated_at') + ->orderByDesc('id'); + } else { + $query->latest('updated_at'); + } if ($resource === 'prompts') { $query->with('category'); } + if ($resource === 'lessons') { + $query->with('courses:id,title'); + } + $items = $query->paginate(25)->withQueryString(); $items->getCollection()->transform(fn (Model $model): array => $this->serializeIndexItem($resource, $model)); @@ -500,6 +646,45 @@ final class AcademyAdminController extends Controller 'items' => $items, 'columns' => $meta['columns'], 'createUrl' => route($meta['route_base'].'.create'), + 'filters' => [ + 'search' => $search, + ], + 'summary' => $resource === 'courses' ? [ + 'total' => (int) $items->total(), + 'published' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void { + $like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%'; + + $builder->where(function ($inner) use ($like): void { + $inner->where('title', 'like', $like) + ->orWhere('slug', 'like', $like) + ->orWhere('subtitle', 'like', $like) + ->orWhere('excerpt', 'like', $like) + ->orWhere('description', 'like', $like); + }); + })->where('status', AcademyCourse::STATUS_PUBLISHED)->count(), + 'featured' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void { + $like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%'; + + $builder->where(function ($inner) use ($like): void { + $inner->where('title', 'like', $like) + ->orWhere('slug', 'like', $like) + ->orWhere('subtitle', 'like', $like) + ->orWhere('excerpt', 'like', $like) + ->orWhere('description', 'like', $like); + }); + })->where('is_featured', true)->count(), + 'drafts' => (int) (clone $meta['model']::query())->when($search !== '', function ($builder) use ($search): void { + $like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $search).'%'; + + $builder->where(function ($inner) use ($like): void { + $inner->where('title', 'like', $like) + ->orWhere('slug', 'like', $like) + ->orWhere('subtitle', 'like', $like) + ->orWhere('excerpt', 'like', $like) + ->orWhere('description', 'like', $like); + }); + })->where('status', AcademyCourse::STATUS_DRAFT)->count(), + ] : null, ]); } @@ -538,6 +723,9 @@ final class AcademyAdminController extends Controller 'outlineSummary' => $record instanceof AcademyCourse && $record->exists ? $this->serializeCourseOutlineSummary($record) : null, + 'courseSections' => $record instanceof AcademyCourse && $record->exists + ? $this->serializeCourseEditorSections($record) + : [], 'courseLessons' => $record instanceof AcademyCourse && $record->exists ? $this->serializeCourseEditorLessons($record) : [], @@ -547,9 +735,19 @@ final class AcademyAdminController extends Controller 'attachLessonUrl' => $record instanceof AcademyCourse && $record->exists ? route('admin.academy.courses.lessons.attach', ['academyCourse' => $record]) : null, + 'importLessonsUrl' => $record instanceof AcademyCourse && $record->exists + ? route('admin.academy.courses.lessons.import', ['academyCourse' => $record]) + : null, + 'sectionStoreUrl' => $record instanceof AcademyCourse && $record->exists + ? route('admin.academy.courses.sections.store', ['academyCourse' => $record]) + : null, 'reorderUrl' => $record instanceof AcademyCourse && $record->exists ? route('admin.academy.courses.reorder', ['academyCourse' => $record]) : null, + 'courseImportUrl' => $record instanceof AcademyCourse && ! $record->exists + ? route('admin.academy.courses.import-json') + : null, + 'lessonCategoryOptions' => $this->categoriesForEditor('lesson'), ]; } @@ -656,7 +854,7 @@ final class AcademyAdminController extends Controller 'singular' => 'lesson', 'subtitle' => 'Create and publish Academy lessons.', 'route_base' => 'admin.academy.lessons', - 'columns' => ['title', 'difficulty', 'access_level', 'featured', 'active'], + 'columns' => ['title', 'course_names', 'course_order', 'difficulty', 'access_level', 'active'], 'fields' => [ ['name' => 'category_id', 'label' => 'Category', 'type' => 'select', 'options' => $this->categoryOptions('lesson')], ['name' => 'course_ids', 'label' => 'Courses', 'type' => 'multiselect', 'options' => $this->courseOptions()], @@ -694,6 +892,10 @@ final class AcademyAdminController extends Controller ['name' => 'negative_prompt', 'label' => 'Negative Prompt', 'type' => 'textarea'], ['name' => 'usage_notes', 'label' => 'Usage Notes', 'type' => 'textarea'], ['name' => 'workflow_notes', 'label' => 'Workflow Notes', 'type' => 'textarea'], + ['name' => 'documentation', 'label' => 'Documentation JSON', 'type' => 'json'], + ['name' => 'placeholders', 'label' => 'Placeholders JSON', 'type' => 'json'], + ['name' => 'helper_prompts', 'label' => 'Helper Prompts JSON', 'type' => 'json'], + ['name' => 'prompt_variants', 'label' => 'Prompt Variants JSON', 'type' => 'json'], ['name' => 'difficulty', 'label' => 'Difficulty', 'type' => 'select', 'options' => $this->difficultyOptions()], ['name' => 'access_level', 'label' => 'Access', 'type' => 'select', 'options' => $this->accessOptions()], ['name' => 'aspect_ratio', 'label' => 'Aspect Ratio', 'type' => 'text'], @@ -785,10 +987,17 @@ final class AcademyAdminController extends Controller 'courses' => [ 'id' => (int) $model->id, 'title' => (string) $model->title, + 'slug' => (string) $model->slug, + 'subtitle' => (string) ($model->subtitle ?? ''), + 'excerpt' => (string) ($model->excerpt ?? ''), + 'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($model->cover_image ?? '')), + 'lessons_count' => (int) ($model->lessons_count_cache ?? $model->course_lessons_count ?? 0), 'difficulty' => (string) $model->difficulty, 'access_level' => (string) $model->access_level, 'status' => (string) $model->status, 'is_featured' => (bool) $model->is_featured, + 'published_at' => optional($model->published_at)->toIso8601String(), + 'updated_at' => optional($model->updated_at)->toIso8601String(), 'edit_url' => route('admin.academy.courses.edit', ['academyCourse' => $model]), 'destroy_url' => route('admin.academy.courses.destroy', ['academyCourse' => $model]), 'builder_url' => route('admin.academy.courses.builder.edit', ['academyCourse' => $model]), @@ -805,6 +1014,8 @@ final class AcademyAdminController extends Controller 'lessons' => [ 'id' => (int) $model->id, 'title' => (string) $model->title, + 'course_names' => $model->courses->pluck('title')->filter()->values()->all(), + 'course_order' => $model->course_order, 'difficulty' => (string) $model->difficulty, 'access_level' => (string) $model->access_level, 'featured' => (bool) $model->featured, @@ -941,6 +1152,10 @@ final class AcademyAdminController extends Controller 'negative_prompt' => (string) ($record->negative_prompt ?? ''), 'usage_notes' => (string) ($record->usage_notes ?? ''), 'workflow_notes' => (string) ($record->workflow_notes ?? ''), + 'documentation' => $this->encodePrettyJsonForForm($record->documentation), + 'placeholders' => $this->encodePrettyJsonForForm($record->placeholders), + 'helper_prompts' => $this->encodePrettyJsonForForm($record->helper_prompts), + 'prompt_variants' => $this->encodePrettyJsonForForm($record->prompt_variants), 'difficulty' => (string) ($record->difficulty ?? 'beginner'), 'access_level' => (string) ($record->access_level ?? 'free'), 'aspect_ratio' => (string) ($record->aspect_ratio ?? ''), @@ -1464,9 +1679,46 @@ final class AcademyAdminController extends Controller return $validated; } + private function saveCourseFromRequest(UpsertAcademyCourseRequest $request, ?AcademyCourse $course = null): AcademyCourse + { + $course ??= new AcademyCourse; + $course->fill($this->persistCourseAttributes($request, $course))->save(); + + return $course; + } + /** * @return array */ + /** + * @return array> + */ + private function serializeCourseEditorSections(AcademyCourse $course): array + { + $course->loadMissing(['sections']); + + return $course->sections + ->sortBy([['order_num', 'asc'], ['id', 'asc']]) + ->values() + ->map(fn (AcademyCourseSection $section): array => [ + 'id' => (int) $section->id, + 'title' => (string) $section->title, + 'slug' => (string) ($section->slug ?? ''), + 'description' => (string) ($section->description ?? ''), + 'order_num' => (int) ($section->order_num ?? 0), + 'is_visible' => (bool) ($section->is_visible ?? true), + 'update_url' => route('admin.academy.courses.sections.update', [ + 'academyCourse' => $course, + 'academyCourseSection' => $section, + ]), + 'destroy_url' => route('admin.academy.courses.sections.destroy', [ + 'academyCourse' => $course, + 'academyCourseSection' => $section, + ]), + ]) + ->all(); + } + /** * @return array> */ @@ -1479,12 +1731,17 @@ final class AcademyAdminController extends Controller ->values() ->map(function (AcademyCourseLesson $courseLesson) use ($course): array { $lesson = $courseLesson->lesson; + $publicationMeta = $this->serializeLessonPublicationMeta($lesson instanceof AcademyLesson ? $lesson : null); - return [ + return array_merge([ 'id' => (int) $courseLesson->id, 'lesson_id' => (int) $courseLesson->lesson_id, 'title' => (string) ($lesson?->title ?? 'Untitled lesson'), 'slug' => (string) ($lesson?->slug ?? ''), + 'cover_image' => (string) ($lesson?->cover_image ?? ''), + 'cover_image_url' => $lesson instanceof AcademyLesson + ? $this->resolveLessonCoverImageUrl((string) ($lesson->cover_image ?: $lesson->article_cover_image ?? '')) + : null, 'section_id' => $courseLesson->section_id ? (int) $courseLesson->section_id : null, 'section_title' => (string) ($courseLesson->section?->title ?? ''), 'order_num' => (int) ($courseLesson->order_num ?? 0), @@ -1493,6 +1750,7 @@ final class AcademyAdminController extends Controller 'is_required' => (bool) $courseLesson->is_required, 'difficulty' => (string) ($lesson?->difficulty ?? ''), 'access_level' => (string) ($lesson?->access_level ?? ''), + 'active' => (bool) ($lesson?->active ?? false), 'destroy_url' => route('admin.academy.courses.lessons.destroy', [ 'academyCourse' => $course, 'academyCourseLesson' => $courseLesson, @@ -1500,42 +1758,208 @@ final class AcademyAdminController extends Controller 'edit_url' => $lesson instanceof AcademyLesson ? route('admin.academy.lessons.edit', ['academyLesson' => $lesson]) : null, - ]; + ], $publicationMeta); }) ->all(); } + /** + * @param array $lessonData + * @param array $defaults + * @param array $reservedSlugs + * @return array + */ + private function buildImportedCourseLessonAttributes(AcademyCourse $course, array $lessonData, array $defaults, array &$reservedSlugs): array + { + $title = trim((string) ($lessonData['title'] ?? '')); + $slugSource = $this->nullableTrimmedString($lessonData['slug'] ?? null) ?? $title; + $excerpt = $this->nullableTrimmedString($lessonData['excerpt'] ?? null) + ?? $this->nullableTrimmedString($lessonData['goal'] ?? null); + $difficulty = $this->nullableTrimmedString($lessonData['difficulty'] ?? null) + ?? $this->nullableTrimmedString($defaults['difficulty'] ?? null) + ?? $this->nullableTrimmedString($course->difficulty) + ?? 'beginner'; + $accessLevel = $this->nullableTrimmedString($lessonData['access_level'] ?? null) + ?? $this->nullableTrimmedString($defaults['access_level'] ?? null) + ?? 'free'; + $lessonType = $this->nullableTrimmedString($lessonData['lesson_type'] ?? null) + ?? $this->nullableTrimmedString($defaults['lesson_type'] ?? null) + ?? 'article'; + $seriesName = $this->nullableTrimmedString($lessonData['series_name'] ?? null) + ?? $this->nullableTrimmedString($defaults['series_name'] ?? null) + ?? $this->nullableTrimmedString($course->title); + $active = array_key_exists('active', $lessonData) + ? (bool) $lessonData['active'] + : (array_key_exists('active', $defaults) ? (bool) $defaults['active'] : false); + + return [ + 'category_id' => $this->resolveImportedLessonCategoryId($lessonData, $defaults), + 'title' => $title, + 'slug' => $this->reserveImportedLessonSlug($slugSource, $reservedSlugs), + 'lesson_number' => null, + 'course_order' => null, + 'series_name' => $seriesName, + 'excerpt' => $excerpt, + 'content' => null, + 'content_markdown' => null, + 'difficulty' => $difficulty, + 'access_level' => $accessLevel, + 'lesson_type' => $lessonType, + 'cover_image' => null, + 'article_cover_image' => null, + 'tags' => collect((array) ($lessonData['tags'] ?? [])) + ->map(fn ($tag): string => trim((string) $tag)) + ->filter(fn (string $tag): bool => $tag !== '') + ->values() + ->all(), + 'video_url' => null, + 'reading_minutes' => 5, + 'featured' => false, + 'active' => $active, + 'published_at' => null, + 'seo_title' => null, + 'seo_description' => $excerpt, + ]; + } + + /** + * @param array $lessonData + * @param array $defaults + */ + private function resolveImportedLessonCategoryId(array $lessonData, array $defaults): ?int + { + foreach ([$lessonData, $defaults] as $source) { + if ($source === []) { + continue; + } + + $categoryId = $source['category_id'] ?? null; + if ($categoryId !== null && AcademyCategory::query()->where('type', 'lesson')->whereKey((int) $categoryId)->exists()) { + return (int) $categoryId; + } + + $categorySlug = $this->nullableTrimmedString($source['category_slug'] ?? null); + if ($categorySlug !== null) { + $category = AcademyCategory::query()->where('type', 'lesson')->where('slug', $categorySlug)->first(); + if ($category instanceof AcademyCategory) { + return (int) $category->id; + } + } + + $categoryName = $this->nullableTrimmedString($source['category'] ?? null); + if ($categoryName !== null) { + $category = AcademyCategory::query()->where('type', 'lesson')->whereRaw('lower(name) = ?', [Str::lower($categoryName)])->first(); + if ($category instanceof AcademyCategory) { + return (int) $category->id; + } + } + } + + return null; + } + + /** + * @param array $reservedSlugs + */ + private function reserveImportedLessonSlug(string $source, array &$reservedSlugs): string + { + $base = Str::slug($source); + + if ($base === '') { + $base = 'academy-lesson'; + } + + $candidate = $base; + $suffix = 2; + + while (in_array($candidate, $reservedSlugs, true)) { + $candidate = $base.'-'.$suffix; + $suffix++; + } + + $reservedSlugs[] = $candidate; + + return $candidate; + } + + /** + * @return array> + */ + private function categoriesForEditor(string $type): array + { + return AcademyCategory::query() + ->where('type', $type) + ->orderBy('order_num') + ->orderBy('name') + ->get() + ->map(fn (AcademyCategory $category): array => $this->serializeCategoryOption($category)) + ->values() + ->all(); + } + /** * @return array> */ private function serializeCourseAvailableLessons(AcademyCourse $course): array { - $course->loadMissing(['courseLessons']); - - $attachedLessonIds = $course->courseLessons - ->pluck('lesson_id') - ->map(fn ($id): int => (int) $id) - ->flip() - ->all(); - return AcademyLesson::query() + ->whereDoesntHave('courseLessons') ->with('category') ->orderBy('title') ->get() - ->map(fn (AcademyLesson $lesson): array => [ - 'id' => (int) $lesson->id, - 'title' => (string) $lesson->title, - 'slug' => (string) $lesson->slug, - 'difficulty' => (string) $lesson->difficulty, - 'access_level' => (string) $lesson->access_level, - 'active' => (bool) $lesson->active, - 'category' => $lesson->category ? (string) $lesson->category->name : '', - 'attached' => isset($attachedLessonIds[(int) $lesson->id]), - ]) + ->map(function (AcademyLesson $lesson): array { + $publicationMeta = $this->serializeLessonPublicationMeta($lesson); + + return array_merge([ + 'id' => (int) $lesson->id, + 'title' => (string) $lesson->title, + 'slug' => (string) $lesson->slug, + 'cover_image' => (string) ($lesson->cover_image ?? ''), + 'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($lesson->cover_image ?: $lesson->article_cover_image ?? '')), + 'difficulty' => (string) $lesson->difficulty, + 'access_level' => (string) $lesson->access_level, + 'active' => (bool) $lesson->active, + 'category' => $lesson->category ? (string) $lesson->category->name : '', + 'edit_url' => route('admin.academy.lessons.edit', ['academyLesson' => $lesson]), + 'attached' => false, + ], $publicationMeta); + }) ->values() ->all(); } + /** + * @return array + */ + private function serializeLessonPublicationMeta(?AcademyLesson $lesson): array + { + $publishedAt = $lesson?->published_at instanceof Carbon + ? $lesson->published_at->copy() + : null; + + if (! $publishedAt) { + return [ + 'published_at' => null, + 'publication_state' => 'draft', + 'publication_label' => 'Unscheduled', + ]; + } + + if ($publishedAt->isFuture()) { + return [ + 'published_at' => $publishedAt->toIso8601String(), + 'publication_state' => 'scheduled', + 'publication_label' => 'Publishes '.$publishedAt->format('Y-m-d H:i'), + ]; + } + + return [ + 'published_at' => $publishedAt->toIso8601String(), + 'publication_state' => 'published', + 'publication_label' => 'Published', + ]; + } + private function serializeCourseOutlineSummary(AcademyCourse $course): array { $course->loadMissing(['sections', 'courseLessons']); @@ -1734,6 +2158,10 @@ final class AcademyAdminController extends Controller $validated['category_id'] = $this->resolveOrCreatePromptCategoryId($newCategoryName); } + $validated['documentation'] = $this->normalizePromptDocumentation($validated['documentation'] ?? null); + $validated['placeholders'] = $this->normalizePromptPlaceholders($validated['placeholders'] ?? null); + $validated['helper_prompts'] = $this->normalizePromptHelperPrompts($validated['helper_prompts'] ?? null); + $validated['prompt_variants'] = $this->normalizePromptVariants($validated['prompt_variants'] ?? null); $validated['tool_notes'] = $this->normalizePromptToolNotes((array) ($validated['tool_notes'] ?? [])); $previousToolNotes = $this->normalizePromptToolNotes((array) ($prompt?->tool_notes ?? [])); @@ -1803,6 +2231,172 @@ final class AcademyAdminController extends Controller ->all(); } + private function encodePrettyJsonForForm(mixed $value): string + { + if ($value === null || $value === [] || $value === '') { + return ''; + } + + return (string) json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + /** + * @return array|null + */ + private function normalizePromptDocumentation(mixed $documentation): ?array + { + if (! is_array($documentation)) { + return null; + } + + $listFields = ['best_for', 'how_to_use', 'required_inputs', 'workflow', 'tips', 'common_mistakes', 'data_accuracy_notes']; + $normalized = [ + 'summary' => $this->nullableTrimmedString($documentation['summary'] ?? null), + 'display_notes' => $this->nullableTrimmedString($documentation['display_notes'] ?? null), + ]; + + foreach ($listFields as $field) { + $normalized[$field] = $this->normalizePromptStringList($documentation[$field] ?? []); + } + + $hasContent = $normalized['summary'] !== null + || $normalized['display_notes'] !== null + || collect($listFields)->contains(fn (string $field): bool => $normalized[$field] !== []); + + return $hasContent ? $normalized : null; + } + + /** + * @return array> + */ + private function normalizePromptPlaceholders(mixed $placeholders): array + { + if (! is_array($placeholders)) { + return []; + } + + return collect($placeholders) + ->filter(static fn ($placeholder): bool => is_array($placeholder)) + ->map(function (array $placeholder): array { + return [ + 'key' => $this->nullableTrimmedString($placeholder['key'] ?? null), + 'label' => $this->nullableTrimmedString($placeholder['label'] ?? null), + 'description' => $this->nullableTrimmedString($placeholder['description'] ?? null), + 'required' => filter_var($placeholder['required'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false, + 'example' => $this->normalizePromptJsonValue($placeholder['example'] ?? null), + 'default' => $this->normalizePromptJsonValue($placeholder['default'] ?? null), + 'type' => $this->nullableTrimmedString($placeholder['type'] ?? null), + ]; + }) + ->filter(function (array $placeholder): bool { + return collect([ + $placeholder['key'] ?? null, + $placeholder['label'] ?? null, + $placeholder['description'] ?? null, + $placeholder['example'] ?? null, + $placeholder['default'] ?? null, + $placeholder['type'] ?? null, + ])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []); + }) + ->values() + ->all(); + } + + /** + * @return array> + */ + private function normalizePromptHelperPrompts(mixed $helperPrompts): array + { + if (! is_array($helperPrompts)) { + return []; + } + + return collect($helperPrompts) + ->filter(static fn ($helperPrompt): bool => is_array($helperPrompt)) + ->map(function (array $helperPrompt): array { + return [ + 'title' => $this->nullableTrimmedString($helperPrompt['title'] ?? null), + 'type' => $this->nullableTrimmedString($helperPrompt['type'] ?? null) ?? 'other', + 'description' => $this->nullableTrimmedString($helperPrompt['description'] ?? null), + 'prompt' => $this->nullableTrimmedString($helperPrompt['prompt'] ?? null), + 'expected_output' => $this->nullableTrimmedString($helperPrompt['expected_output'] ?? null) ?? 'text', + 'active' => filter_var($helperPrompt['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true, + ]; + }) + ->filter(function (array $helperPrompt): bool { + return collect([ + $helperPrompt['title'] ?? null, + $helperPrompt['description'] ?? null, + $helperPrompt['prompt'] ?? null, + ])->contains(fn ($item): bool => $item !== null && $item !== ''); + }) + ->values() + ->all(); + } + + /** + * @return array> + */ + private function normalizePromptVariants(mixed $variants): array + { + if (! is_array($variants)) { + return []; + } + + return collect($variants) + ->filter(static fn ($variant): bool => is_array($variant)) + ->map(function (array $variant): array { + return [ + 'title' => $this->nullableTrimmedString($variant['title'] ?? null), + 'slug' => $this->nullableTrimmedString($variant['slug'] ?? null), + 'description' => $this->nullableTrimmedString($variant['description'] ?? null), + 'prompt' => $this->nullableTrimmedString($variant['prompt'] ?? null), + 'negative_prompt' => $this->nullableTrimmedString($variant['negative_prompt'] ?? null), + 'recommended' => filter_var($variant['recommended'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false, + 'recommended_for' => $this->normalizePromptStringList($variant['recommended_for'] ?? []), + 'risk_notes' => $this->normalizePromptStringList($variant['risk_notes'] ?? []), + 'active' => filter_var($variant['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true, + ]; + }) + ->filter(function (array $variant): bool { + return collect([ + $variant['title'] ?? null, + $variant['description'] ?? null, + $variant['prompt'] ?? null, + $variant['negative_prompt'] ?? null, + ])->contains(fn ($item): bool => $item !== null && $item !== ''); + }) + ->values() + ->all(); + } + + /** + * @return array + */ + private function normalizePromptStringList(mixed $value): array + { + if (! is_array($value)) { + $value = $value === null ? [] : [$value]; + } + + return collect($value) + ->map(fn ($item): string => trim((string) $item)) + ->filter(static fn (string $item): bool => $item !== '') + ->values() + ->all(); + } + + private function normalizePromptJsonValue(mixed $value): mixed + { + if (! is_string($value)) { + return $value; + } + + $trimmed = trim($value); + + return $trimmed !== '' ? $trimmed : null; + } + /** * @param array $notes * @return array> @@ -1966,6 +2560,23 @@ final class AcademyAdminController extends Controller $storedPath = self::PROMPT_PREVIEW_PREFIX.'/'.pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME).'.webp'; Storage::disk($this->promptPreviewImageDisk())->put($storedPath, $webpBinary, ['visibility' => 'public']); + + $sourceWidth = imagesx($image); + $sourceHeight = imagesy($image); + + foreach (self::PROMPT_PREVIEW_VARIANT_WIDTHS as $variant => $targetWidth) { + $variantBinary = $this->encodePromptPreviewVariant($image, $targetWidth, $sourceWidth, $sourceHeight); + + if ($variantBinary === null) { + continue; + } + + Storage::disk($this->promptPreviewImageDisk())->put( + $this->promptPreviewVariantPath($storedPath, $variant), + $variantBinary, + ['visibility' => 'public'] + ); + } } finally { imagedestroy($image); } @@ -1973,6 +2584,62 @@ final class AcademyAdminController extends Controller return $storedPath; } + private function encodePromptPreviewVariant(\GdImage $source, int $targetWidth, int $sourceWidth, int $sourceHeight): ?string + { + if ($sourceWidth <= $targetWidth || $sourceWidth < 1 || $sourceHeight < 1) { + return null; + } + + $targetHeight = max(1, (int) round(($sourceHeight / $sourceWidth) * $targetWidth)); + $variant = imagecreatetruecolor($targetWidth, $targetHeight); + + if (! $variant instanceof \GdImage) { + throw ValidationException::withMessages([ + 'preview_image_file' => 'The uploaded preview image could not be resized. Please try a different image.', + ]); + } + + imagealphablending($variant, false); + imagesavealpha($variant, true); + $transparent = imagecolorallocatealpha($variant, 0, 0, 0, 127); + imagefilledrectangle($variant, 0, 0, $targetWidth, $targetHeight, $transparent); + imagecopyresampled($variant, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $sourceWidth, $sourceHeight); + + try { + ob_start(); + $converted = imagewebp($variant, null, self::PROMPT_PREVIEW_WEBP_QUALITY); + $webpBinary = ob_get_clean(); + + if (! $converted || ! is_string($webpBinary) || $webpBinary === '') { + throw ValidationException::withMessages([ + 'preview_image_file' => 'The uploaded preview image could not be converted to WebP. Please try a different image.', + ]); + } + + return $webpBinary; + } finally { + imagedestroy($variant); + } + } + + private function promptPreviewVariantPath(string $path, string $variant): string + { + $directory = pathinfo($path, PATHINFO_DIRNAME); + $filename = pathinfo($path, PATHINFO_FILENAME); + $baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename; + + return sprintf('%s/%s-%s.webp', $directory, $baseFilename, $variant); + } + + private function canonicalPromptPreviewPath(string $path): string + { + $directory = pathinfo($path, PATHINFO_DIRNAME); + $filename = pathinfo($path, PATHINFO_FILENAME); + $baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename; + + return sprintf('%s/%s.webp', $directory, $baseFilename); + } + private function deleteStoredPromptPreviewIfLocal(?string $path): void { $path = trim((string) $path); @@ -1985,10 +2652,14 @@ final class AcademyAdminController extends Controller } $disk = $this->promptPreviewImageDisk(); + $basePath = $this->canonicalPromptPreviewPath($path); + $paths = [ + $basePath, + $this->promptPreviewVariantPath($basePath, 'thumb'), + $this->promptPreviewVariantPath($basePath, 'md'), + ]; - if (Storage::disk($disk)->exists($path)) { - Storage::disk($disk)->delete($path); - } + Storage::disk($disk)->delete(array_values(array_unique($paths))); } private function promptPreviewImageUploadErrorMessage(UploadedFile $file): string diff --git a/app/Http/Controllers/Settings/AcademyLessonMediaApiController.php b/app/Http/Controllers/Settings/AcademyLessonMediaApiController.php index b6491047..440e2710 100644 --- a/app/Http/Controllers/Settings/AcademyLessonMediaApiController.php +++ b/app/Http/Controllers/Settings/AcademyLessonMediaApiController.php @@ -26,6 +26,11 @@ final class AcademyLessonMediaApiController extends Controller private const ASSET_CACHE_TTL_MINUTES = 15; + private const RESPONSIVE_VARIANT_WIDTHS = [ + 'thumb' => 480, + 'md' => 960, + ]; + private ?ImageManager $manager = null; public function __construct() @@ -68,6 +73,18 @@ final class AcademyLessonMediaApiController extends Controller 'slot' => $slot, 'path' => $stored['path'], 'url' => $this->publicUrlForPath($stored['path']), + 'thumb_path' => $stored['thumb_path'], + 'thumb_url' => $this->publicUrlForPath($stored['thumb_path']), + 'thumb_width' => $stored['thumb_width'], + 'thumb_height' => $stored['thumb_height'], + 'medium_path' => $stored['medium_path'], + 'medium_url' => $stored['medium_path'] !== '' ? $this->publicUrlForPath($stored['medium_path']) : null, + 'medium_width' => $stored['medium_width'], + 'medium_height' => $stored['medium_height'], + 'srcset' => $this->buildResponsiveSrcset([ + ['path' => $stored['thumb_path'], 'width' => $stored['thumb_width']], + ['path' => $stored['medium_path'], 'width' => $stored['medium_width']], + ]), 'width' => $stored['width'], 'height' => $stored['height'], 'mime_type' => 'image/webp', @@ -161,7 +178,7 @@ final class AcademyLessonMediaApiController extends Controller } /** - * @return array{path:string,width:int,height:int,size_bytes:int} + * @return array{path:string,thumb_path:string,thumb_width:int,thumb_height:int,medium_path:string,medium_width:int|null,medium_height:int|null,width:int,height:int,size_bytes:int} */ private function storeMediaFile(UploadedFile $file, string $slot): array { @@ -202,14 +219,99 @@ final class AcademyLessonMediaApiController extends Controller )); } - $image = $this->manager->read($raw)->scaleDown(width: $constraints['max_width'], height: $constraints['max_height']); - $encoded = (string) $image->encode(new WebpEncoder(85)); + $encodedImage = $this->encodeScaledMedia($raw, $constraints['max_width'], $constraints['max_height']); + $encoded = $encodedImage['binary']; $hash = hash('sha256', $encoded); $path = $this->mediaPath($hash, $slot); $disk = Storage::disk($this->mediaDiskName()); - $written = $disk->put($path, $encoded, [ + $this->writeMediaBinary($disk, $path, $encoded); + + $thumbVariant = $this->storeResponsiveVariant( + $disk, + $raw, + $constraints, + $path, + 'thumb', + self::RESPONSIVE_VARIANT_WIDTHS['thumb'], + $encodedImage['width'], + $encodedImage['height'], + ); + + $mediumVariant = $this->storeResponsiveVariant( + $disk, + $raw, + $constraints, + $path, + 'md', + self::RESPONSIVE_VARIANT_WIDTHS['md'], + $encodedImage['width'], + $encodedImage['height'], + ); + + return [ + 'path' => $path, + 'thumb_path' => $thumbVariant['path'] ?? $path, + 'thumb_width' => $thumbVariant['width'] ?? $encodedImage['width'], + 'thumb_height' => $thumbVariant['height'] ?? $encodedImage['height'], + 'medium_path' => $mediumVariant['path'] ?? '', + 'medium_width' => $mediumVariant['width'] ?? null, + 'medium_height' => $mediumVariant['height'] ?? null, + 'width' => $encodedImage['width'], + 'height' => $encodedImage['height'], + 'size_bytes' => strlen($encoded), + ]; + } + + /** + * @return array{binary:string,width:int,height:int} + */ + private function encodeScaledMedia(string $raw, int $maxWidth, int $maxHeight): array + { + $image = $this->manager->read($raw)->scaleDown(width: $maxWidth, height: $maxHeight); + $encoded = (string) $image->encode(new WebpEncoder(85)); + + if ($encoded === '') { + throw new RuntimeException('Unable to encode image to WebP.'); + } + + return [ + 'binary' => $encoded, + 'width' => (int) $image->width(), + 'height' => (int) $image->height(), + ]; + } + + /** + * @param array{max_width:int,max_height:int} $constraints + * @return array{path:string,width:int,height:int}|null + */ + private function storeResponsiveVariant($disk, string $raw, array $constraints, string $path, string $variant, int $targetWidth, int $sourceWidth, int $sourceHeight): ?array + { + if ($sourceWidth <= $targetWidth && $sourceHeight <= $constraints['max_height']) { + return null; + } + + $encodedVariant = $this->encodeScaledMedia($raw, $targetWidth, $constraints['max_height']); + + if ($encodedVariant['width'] >= $sourceWidth && $encodedVariant['height'] >= $sourceHeight) { + return null; + } + + $variantPath = $this->responsiveVariantPath($path, $variant); + $this->writeMediaBinary($disk, $variantPath, $encodedVariant['binary']); + + return [ + 'path' => $variantPath, + 'width' => $encodedVariant['width'], + 'height' => $encodedVariant['height'], + ]; + } + + private function writeMediaBinary($disk, string $path, string $binary): void + { + $written = $disk->put($path, $binary, [ 'visibility' => 'public', 'CacheControl' => 'public, max-age=31536000, immutable', 'ContentType' => 'image/webp', @@ -218,13 +320,6 @@ final class AcademyLessonMediaApiController extends Controller if ($written !== true) { throw new RuntimeException('Unable to store image in object storage.'); } - - return [ - 'path' => $path, - 'width' => (int) $image->width(), - 'height' => (int) $image->height(), - 'size_bytes' => strlen($encoded), - ]; } private function authorizeStaff(Request $request): void @@ -255,6 +350,54 @@ final class AcademyLessonMediaApiController extends Controller return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/'); } + /** + * @param array $variants + */ + private function buildResponsiveSrcset(array $variants): ?string + { + $entries = collect($variants) + ->filter(function (array $variant): bool { + return trim((string) ($variant['path'] ?? '')) !== '' && (int) ($variant['width'] ?? 0) > 0; + }) + ->unique(fn (array $variant): string => trim((string) ($variant['path'] ?? ''))) + ->map(fn (array $variant): string => sprintf('%s %dw', $this->publicUrlForPath((string) $variant['path']), (int) $variant['width'])) + ->values() + ->all(); + + return $entries !== [] ? implode(', ', $entries) : null; + } + + private function responsiveVariantPath(string $path, string $variant): string + { + $directory = pathinfo($path, PATHINFO_DIRNAME); + $filename = pathinfo($path, PATHINFO_FILENAME); + + return sprintf( + '%s/%s-%s.webp', + $directory === '.' ? '' : $directory, + preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename, + $variant, + ); + } + + private function canonicalMediaPath(string $path): string + { + $directory = pathinfo($path, PATHINFO_DIRNAME); + $filename = pathinfo($path, PATHINFO_FILENAME); + $baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename; + + return sprintf( + '%s/%s.webp', + $directory === '.' ? '' : $directory, + $baseFilename, + ); + } + + private function isResponsiveVariantPath(string $path): bool + { + return preg_match('/-(thumb|md)\.webp$/i', $path) === 1; + } + private function academyAssetManifest(): Collection { return Cache::remember($this->academyAssetCacheKey(), now()->addMinutes(self::ASSET_CACHE_TTL_MINUTES), function (): Collection { @@ -262,6 +405,7 @@ final class AcademyLessonMediaApiController extends Controller return collect($disk->allFiles('academy/lessons')) ->filter(fn (string $path): bool => Str::endsWith(Str::lower($path), ['.webp', '.jpg', '.jpeg', '.png'])) + ->reject(fn (string $path): bool => $this->isResponsiveVariantPath($path)) ->map(function (string $path) use ($disk): array { $modifiedAt = null; @@ -323,7 +467,14 @@ final class AcademyLessonMediaApiController extends Controller return; } - Storage::disk($this->mediaDiskName())->delete($trimmed); + $basePath = $this->canonicalMediaPath($trimmed); + $paths = [ + $basePath, + $this->responsiveVariantPath($basePath, 'thumb'), + $this->responsiveVariantPath($basePath, 'md'), + ]; + + Storage::disk($this->mediaDiskName())->delete(array_values(array_unique($paths))); } private function normalizeSlot(mixed $slot): string @@ -346,8 +497,8 @@ final class AcademyLessonMediaApiController extends Controller } return [ - 'min_width' => 1200, - 'min_height' => 630, + 'min_width' => 600, + 'min_height' => 315, 'max_width' => 2200, 'max_height' => 1400, ]; diff --git a/app/Http/Controllers/Settings/WorldWebStoryAdminController.php b/app/Http/Controllers/Settings/WorldWebStoryAdminController.php new file mode 100644 index 00000000..874c19bf --- /dev/null +++ b/app/Http/Controllers/Settings/WorldWebStoryAdminController.php @@ -0,0 +1,512 @@ + trim((string) $request->query('q', '')), + 'status' => trim((string) $request->query('status', 'all')), + ]; + + $stories = WorldWebStory::query() + ->with('world') + ->when($filters['q'] !== '', function ($query) use ($filters): void { + $query->where(function ($nested) use ($filters): void { + $nested->where('title', 'like', '%' . $filters['q'] . '%') + ->orWhere('slug', 'like', '%' . $filters['q'] . '%') + ->orWhereHas('world', fn ($worldQuery) => $worldQuery->where('title', 'like', '%' . $filters['q'] . '%')->orWhere('slug', 'like', '%' . $filters['q'] . '%')); + }); + }) + ->when($filters['status'] !== 'all', fn ($query) => $query->where('status', $filters['status'])) + ->orderByDesc('published_at') + ->orderByDesc('updated_at') + ->paginate(self::PER_PAGE) + ->withQueryString() + ->through(fn (WorldWebStory $story): array => $this->mapStoryListItem($story)); + + return Inertia::render('Moderation/WorldWebStoriesIndex', [ + 'title' => 'World Web Stories', + 'stories' => $stories, + 'filters' => $filters, + 'stats' => [ + 'total' => WorldWebStory::query()->count(), + 'published' => WorldWebStory::query()->where('status', WorldWebStory::STATUS_PUBLISHED)->count(), + 'draft' => WorldWebStory::query()->where('status', WorldWebStory::STATUS_DRAFT)->count(), + 'hidden' => WorldWebStory::query()->where('noindex', true)->orWhere('active', false)->count(), + ], + 'worldOptions' => $this->worldOptions(), + 'endpoints' => [ + 'index' => route('admin.web-stories.index'), + 'create' => route('admin.web-stories.create'), + 'editPattern' => route('admin.web-stories.edit', ['story' => '__STORY__']), + 'destroyPattern' => route('admin.web-stories.destroy', ['story' => '__STORY__']), + 'publishPattern' => route('admin.web-stories.publish', ['story' => '__STORY__']), + 'unpublishPattern' => route('admin.web-stories.unpublish', ['story' => '__STORY__']), + 'generatePattern' => route('admin.web-stories.generate', ['world' => '__WORLD__']), + ], + ])->rootView('moderation'); + } + + public function create(): Response + { + return Inertia::render('Moderation/WorldWebStoryEditor', [ + 'story' => $this->blankStoryPayload(), + 'worldOptions' => $this->worldOptions(), + 'endpoints' => $this->editorEndpoints(), + 'isNew' => true, + ])->rootView('moderation'); + } + + public function store(Request $request): RedirectResponse + { + $attributes = $this->validatedStoryAttributes($request); + $story = new WorldWebStory(); + $story->fill($attributes + [ + 'created_by' => (int) $request->user()->id, + 'updated_by' => (int) $request->user()->id, + ]); + $this->normalizeStatusTimestamps($story); + $this->assertPublishedStateIsValid($story); + $story->save(); + + return redirect()->route('admin.web-stories.edit', ['story' => $story])->with('success', 'Web story created.'); + } + + public function edit(WorldWebStory $story): Response + { + $story->load(['world', 'orderedPages.artwork']); + + return Inertia::render('Moderation/WorldWebStoryEditor', [ + 'story' => $this->mapStoryEditorPayload($story), + 'worldOptions' => $this->worldOptions(), + 'endpoints' => $this->editorEndpoints($story), + 'isNew' => false, + ])->rootView('moderation'); + } + + public function update(Request $request, WorldWebStory $story): RedirectResponse + { + $story->fill($this->validatedStoryAttributes($request) + [ + 'updated_by' => (int) $request->user()->id, + ]); + $this->normalizeStatusTimestamps($story); + $this->assertPublishedStateIsValid($story); + $story->save(); + + return back()->with('success', 'Web story updated.'); + } + + public function destroy(WorldWebStory $story): JsonResponse + { + $story->delete(); + + return response()->json([ + 'ok' => true, + 'message' => 'Web story deleted.', + ]); + } + + public function storePage(Request $request, WorldWebStory $story): JsonResponse + { + $attributes = $this->validatedPageAttributes($request, $story, null); + $page = $story->pages()->create($attributes); + + return response()->json([ + 'ok' => true, + 'message' => 'Page created.', + 'page' => $this->mapPage($page->fresh('artwork')), + ]); + } + + public function updatePage(Request $request, WorldWebStory $story, WorldWebStoryPage $page): JsonResponse + { + abort_unless((int) $page->story_id === (int) $story->id, 404); + + $page->fill($this->validatedPageAttributes($request, $story, $page)); + $page->save(); + + return response()->json([ + 'ok' => true, + 'message' => 'Page updated.', + 'page' => $this->mapPage($page->fresh('artwork')), + ]); + } + + public function destroyPage(WorldWebStory $story, WorldWebStoryPage $page): JsonResponse + { + abort_unless((int) $page->story_id === (int) $story->id, 404); + + $page->delete(); + + return response()->json([ + 'ok' => true, + 'message' => 'Page deleted.', + ]); + } + + public function reorderPages(Request $request, WorldWebStory $story): JsonResponse + { + $validated = $request->validate([ + 'page_ids' => ['required', 'array', 'min:1'], + 'page_ids.*' => ['integer'], + ]); + + $ids = collect($validated['page_ids'])->map(fn ($id): int => (int) $id)->values(); + $pages = $story->orderedPages()->whereIn('id', $ids)->get()->keyBy('id'); + + abort_unless($pages->count() === $ids->count(), 422); + + foreach ($ids as $index => $id) { + $pages[$id]->forceFill(['position' => $index + 1])->save(); + } + + return response()->json([ + 'ok' => true, + 'message' => 'Page order updated.', + ]); + } + + public function generateFromWorld(Request $request, World $world): JsonResponse + { + $validated = $request->validate([ + 'force' => ['nullable', 'boolean'], + 'publish' => ['nullable', 'boolean'], + 'dry_run' => ['nullable', 'boolean'], + 'pages' => ['nullable', 'integer', 'min:5', 'max:10'], + ]); + + $result = $this->generator->generateFromWorld( + $world, + $request->user(), + (int) ($validated['pages'] ?? 7), + (bool) ($validated['force'] ?? false), + (bool) ($validated['publish'] ?? false), + (bool) ($validated['dry_run'] ?? false), + ); + + return response()->json([ + 'ok' => true, + 'message' => $result['created'] ? 'Web story draft generated.' : 'Web story draft regenerated.', + 'story' => [ + 'id' => $result['story']->id, + 'slug' => $result['story']->slug, + 'edit_url' => $result['story']->exists ? route('admin.web-stories.edit', ['story' => $result['story']->id]) : null, + ], + 'validation' => $result['validation'], + ]); + } + + public function publish(WorldWebStory $story): JsonResponse + { + $this->assets->buildAssets($story, force: false); + $story->refresh()->load('orderedPages'); + $this->validation->assertPublishable($story); + $story->forceFill([ + 'status' => WorldWebStory::STATUS_PUBLISHED, + 'published_at' => $story->published_at ?: now(), + ])->save(); + + return response()->json([ + 'ok' => true, + 'message' => 'Web story published.', + ]); + } + + public function unpublish(WorldWebStory $story): JsonResponse + { + $story->forceFill([ + 'status' => WorldWebStory::STATUS_DRAFT, + 'published_at' => null, + ])->save(); + + return response()->json([ + 'ok' => true, + 'message' => 'Web story reverted to draft.', + ]); + } + + /** + * @return array + */ + private function validatedStoryAttributes(Request $request, ?WorldWebStory $story = null): array + { + $validated = $request->validate([ + 'world_id' => ['nullable', 'integer', Rule::exists('worlds', 'id')], + 'slug' => ['required', 'string', 'max:120', Rule::unique('world_web_stories', 'slug')->ignore($story?->id)], + 'title' => ['required', 'string', 'max:255'], + 'subtitle' => ['nullable', 'string', 'max:255'], + 'excerpt' => ['nullable', 'string', 'max:400'], + 'description' => ['nullable', 'string', 'max:2000'], + 'seo_title' => ['nullable', 'string', 'max:255'], + 'seo_description' => ['nullable', 'string', 'max:400'], + 'poster_portrait_path' => ['nullable', 'string', 'max:2048'], + 'poster_square_path' => ['nullable', 'string', 'max:2048'], + 'publisher_logo_path' => ['nullable', 'string', 'max:2048'], + 'status' => ['required', Rule::in([WorldWebStory::STATUS_DRAFT, WorldWebStory::STATUS_PUBLISHED, WorldWebStory::STATUS_ARCHIVED])], + 'featured' => ['required', 'boolean'], + 'active' => ['required', 'boolean'], + 'noindex' => ['required', 'boolean'], + 'published_at' => ['nullable', 'date'], + 'starts_at' => ['nullable', 'date'], + 'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'], + ]); + + return $validated; + } + + /** + * @return array + */ + private function validatedPageAttributes(Request $request, WorldWebStory $story, ?WorldWebStoryPage $page): array + { + $validated = $request->validate([ + 'artwork_id' => ['nullable', 'integer', Rule::exists('artworks', 'id')], + 'position' => ['nullable', 'integer', 'min:1'], + 'layout' => ['required', Rule::in([ + WorldWebStoryPage::LAYOUT_COVER, + WorldWebStoryPage::LAYOUT_ARTWORK, + WorldWebStoryPage::LAYOUT_CREATOR, + WorldWebStoryPage::LAYOUT_MOOD, + WorldWebStoryPage::LAYOUT_COLLECTION, + WorldWebStoryPage::LAYOUT_CTA, + ])], + 'background_type' => ['required', Rule::in([ + WorldWebStoryPage::BACKGROUND_IMAGE, + WorldWebStoryPage::BACKGROUND_VIDEO, + WorldWebStoryPage::BACKGROUND_GRADIENT, + ])], + 'background_path' => ['nullable', 'string', 'max:2048'], + 'background_mobile_path' => ['nullable', 'string', 'max:2048'], + 'headline' => ['nullable', 'string', 'max:255'], + 'body' => ['nullable', 'string', 'max:180'], + 'cta_label' => ['nullable', 'string', 'max:120'], + 'cta_url' => ['nullable', 'string', 'max:2048'], + 'alt_text' => ['required', 'string', 'max:255'], + 'caption' => ['nullable', 'string', 'max:120'], + 'credit_text' => ['nullable', 'string', 'max:255'], + 'text_position' => ['required', Rule::in(['top', 'center', 'bottom'])], + 'overlay_strength' => ['required', 'integer', 'min:0', 'max:100'], + 'animation' => ['nullable', Rule::in(['fade-in', 'fly-in-bottom', 'pulse', 'pan-left', 'pan-right'])], + 'active' => ['required', 'boolean'], + ]); + + $validated['position'] = (int) ($validated['position'] ?? ($story->orderedPages()->max('position') + ($page ? 0 : 1) ?: 1)); + $pageErrors = $this->validation->validatePagePayload($validated); + + if ($pageErrors !== []) { + throw ValidationException::withMessages($pageErrors); + } + + return $validated; + } + + private function normalizeStatusTimestamps(WorldWebStory $story): void + { + if ((string) $story->status === WorldWebStory::STATUS_PUBLISHED && $story->published_at === null) { + $story->published_at = now(); + } + + if ((string) $story->status === WorldWebStory::STATUS_DRAFT) { + $story->published_at = null; + } + } + + private function assertPublishedStateIsValid(WorldWebStory $story): void + { + if ((string) $story->status !== WorldWebStory::STATUS_PUBLISHED) { + return; + } + + $story->loadMissing('orderedPages'); + $this->validation->assertPublishable($story); + } + + /** + * @return array + */ + private function worldOptions(): array + { + return World::query() + ->orderByDesc('published_at') + ->orderBy('title') + ->limit(200) + ->get(['id', 'title', 'slug']) + ->map(fn (World $world): array => [ + 'value' => (int) $world->id, + 'label' => (string) $world->title, + 'description' => (string) $world->slug, + ]) + ->all(); + } + + /** + * @return array + */ + private function blankStoryPayload(): array + { + return [ + 'id' => null, + 'world_id' => null, + 'slug' => '', + 'title' => '', + 'subtitle' => '', + 'excerpt' => '', + 'description' => '', + 'seo_title' => '', + 'seo_description' => '', + 'poster_portrait_path' => '', + 'poster_square_path' => '', + 'publisher_logo_path' => $this->assets->defaultPublisherLogoPath(), + 'status' => WorldWebStory::STATUS_DRAFT, + 'featured' => false, + 'active' => true, + 'noindex' => false, + 'published_at' => null, + 'starts_at' => null, + 'ends_at' => null, + 'world' => null, + 'pages' => [], + 'public_url' => null, + 'validation' => ['valid' => false, 'errors' => [], 'warnings' => [], 'page_count' => 0], + ]; + } + + /** + * @return array + */ + private function mapStoryEditorPayload(WorldWebStory $story): array + { + return [ + 'id' => (int) $story->id, + 'world_id' => $story->world_id ? (int) $story->world_id : null, + 'slug' => (string) $story->slug, + 'title' => (string) $story->title, + 'subtitle' => (string) ($story->subtitle ?? ''), + 'excerpt' => (string) ($story->excerpt ?? ''), + 'description' => (string) ($story->description ?? ''), + 'seo_title' => (string) ($story->seo_title ?? ''), + 'seo_description' => (string) ($story->seo_description ?? ''), + 'poster_portrait_path' => (string) ($story->poster_portrait_path ?? ''), + 'poster_square_path' => (string) ($story->poster_square_path ?? ''), + 'publisher_logo_path' => (string) ($story->publisher_logo_path ?? ''), + 'status' => (string) $story->status, + 'featured' => (bool) $story->featured, + 'active' => (bool) $story->active, + 'noindex' => (bool) $story->noindex, + 'published_at' => optional($story->published_at)?->toIso8601String(), + 'starts_at' => optional($story->starts_at)?->toIso8601String(), + 'ends_at' => optional($story->ends_at)?->toIso8601String(), + 'world' => $story->world ? [ + 'id' => (int) $story->world->id, + 'title' => (string) $story->world->title, + 'slug' => (string) $story->world->slug, + ] : null, + 'pages' => $story->orderedPages->map(fn (WorldWebStoryPage $page): array => $this->mapPage($page))->all(), + 'public_url' => route('web-stories.show', ['slug' => $story->slug]), + 'validation' => $this->validation->validate($story), + ]; + } + + /** + * @return array + */ + private function mapStoryListItem(WorldWebStory $story): array + { + return [ + 'id' => (int) $story->id, + 'slug' => (string) $story->slug, + 'title' => (string) $story->title, + 'excerpt' => (string) ($story->excerpt ?? ''), + 'status' => (string) $story->status, + 'active' => (bool) $story->active, + 'noindex' => (bool) $story->noindex, + 'featured' => (bool) $story->featured, + 'page_count' => (int) ($story->pages()->count()), + 'published_at' => optional($story->published_at)?->toIso8601String(), + 'poster_portrait_url' => $story->posterPortraitUrl(), + 'world' => $story->world ? [ + 'id' => (int) $story->world->id, + 'title' => (string) $story->world->title, + 'slug' => (string) $story->world->slug, + ] : null, + 'public_url' => route('web-stories.show', ['slug' => $story->slug]), + ]; + } + + /** + * @return array + */ + private function mapPage(WorldWebStoryPage $page): array + { + return [ + 'id' => (int) $page->id, + 'artwork_id' => $page->artwork_id ? (int) $page->artwork_id : null, + 'position' => (int) $page->position, + 'layout' => (string) $page->layout, + 'background_type' => (string) $page->background_type, + 'background_path' => (string) ($page->background_path ?? ''), + 'background_mobile_path' => (string) ($page->background_mobile_path ?? ''), + 'headline' => (string) ($page->headline ?? ''), + 'body' => (string) ($page->body ?? ''), + 'cta_label' => (string) ($page->cta_label ?? ''), + 'cta_url' => (string) ($page->cta_url ?? ''), + 'alt_text' => (string) ($page->alt_text ?? ''), + 'caption' => (string) ($page->caption ?? ''), + 'credit_text' => (string) ($page->credit_text ?? ''), + 'text_position' => (string) ($page->text_position ?? 'bottom'), + 'overlay_strength' => (int) ($page->overlay_strength ?? 35), + 'animation' => (string) ($page->animation ?? ''), + 'active' => (bool) $page->active, + 'background_url' => $page->backgroundUrl(), + ]; + } + + /** + * @return array + */ + private function editorEndpoints(?WorldWebStory $story = null): array + { + return [ + 'store' => route('admin.web-stories.store'), + 'update' => $story ? route('admin.web-stories.update', ['story' => $story]) : '', + 'destroy' => $story ? route('admin.web-stories.destroy', ['story' => $story]) : '', + 'pagesStore' => $story ? route('admin.web-stories.pages.store', ['story' => $story]) : '', + 'pagesUpdatePattern' => $story ? route('admin.web-stories.pages.update', ['story' => $story, 'page' => '__PAGE__']) : '', + 'pagesDestroyPattern' => $story ? route('admin.web-stories.pages.destroy', ['story' => $story, 'page' => '__PAGE__']) : '', + 'pagesReorder' => $story ? route('admin.web-stories.pages.reorder', ['story' => $story]) : '', + 'publish' => $story ? route('admin.web-stories.publish', ['story' => $story]) : '', + 'unpublish' => $story ? route('admin.web-stories.unpublish', ['story' => $story]) : '', + 'generateFromWorldPattern' => route('admin.web-stories.generate', ['world' => '__WORLD__']), + 'index' => route('admin.web-stories.index'), + ]; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Studio/StudioNewsController.php b/app/Http/Controllers/Studio/StudioNewsController.php index 4e186d40..7c6373cf 100644 --- a/app/Http/Controllers/Studio/StudioNewsController.php +++ b/app/Http/Controllers/Studio/StudioNewsController.php @@ -32,7 +32,7 @@ final class StudioNewsController extends Controller return Inertia::render('Studio/StudioNewsIndex', [ 'title' => 'Newsroom', 'description' => 'Plan announcements, publish editorial stories, and connect articles to the rest of Nova.', - 'listing' => $this->news->studioListing($request->only(['q', 'status', 'type', 'category_id', 'per_page', 'page'])), + 'listing' => $this->news->studioListing($request->only(['q', 'status', 'type', 'category_id', 'per_page', 'page', 'order', 'direction'])), 'statusOptions' => $this->news->editorialStatusOptions(), 'typeOptions' => $this->news->articleTypeOptions(), 'categoryOptions' => $this->news->categoryOptions(), @@ -56,7 +56,7 @@ final class StudioNewsController extends Controller 'statusOptions' => $this->news->editorialStatusOptions(), 'categoryOptions' => $this->news->categoryOptions(), 'tagOptions' => $this->news->tagOptions(), - 'newsTagLimit' => 12, + 'newsTagLimit' => 30, 'relationTypeOptions' => $this->news->relationTypeOptions(), 'storeUrl' => route('studio.news.store'), 'coverUploadUrl' => route('api.studio.news.media.upload'), @@ -92,7 +92,7 @@ final class StudioNewsController extends Controller 'statusOptions' => $this->news->editorialStatusOptions(), 'categoryOptions' => $this->news->categoryOptions(), 'tagOptions' => $this->news->tagOptions(), - 'newsTagLimit' => 12, + 'newsTagLimit' => 30, 'relationTypeOptions' => $this->news->relationTypeOptions(), 'coverUploadUrl' => route('api.studio.news.media.upload'), 'coverDeleteUrl' => route('api.studio.news.media.destroy'), @@ -367,21 +367,11 @@ final class StudioNewsController extends Controller 'comments_enabled' => ['nullable', 'boolean'], 'tag_ids' => ['nullable', 'array'], 'tag_ids.*' => ['integer', 'exists:news_tags,id'], - 'new_tag_names' => ['nullable', 'array', 'max:12'], + 'new_tag_names' => ['nullable', 'array', 'max:30'], 'new_tag_names.*' => ['string', 'max:80'], 'meta_title' => ['nullable', 'string', 'max:255'], 'meta_description' => ['nullable', 'string', 'max:300'], 'meta_keywords' => ['nullable', 'string', 'max:255'], - 'canonical_url' => ['nullable', 'string', 'max:2048', function (string $attribute, mixed $value, \Closure $fail): void { - if ($value === '' || $value === null) { - return; - } - $isAbsolute = filter_var($value, FILTER_VALIDATE_URL) !== false; - $isRelative = str_starts_with($value, '/'); - if (! $isAbsolute && ! $isRelative) { - $fail('The canonical URL must be a valid URL or a relative path starting with /.'); - } - }], 'og_title' => ['nullable', 'string', 'max:255'], 'og_description' => ['nullable', 'string', 'max:300'], 'og_image' => ['nullable', 'string', 'max:2048'], diff --git a/app/Http/Controllers/Web/SimilarArtworksPageController.php b/app/Http/Controllers/Web/SimilarArtworksPageController.php index f074cf31..9584871c 100644 --- a/app/Http/Controllers/Web/SimilarArtworksPageController.php +++ b/app/Http/Controllers/Web/SimilarArtworksPageController.php @@ -227,10 +227,11 @@ final class SimilarArtworksPageController extends Controller ->public() ->published() ->with([ - 'categories:id,slug,name', + 'categories:id,slug,name,content_type_id', 'categories.contentType:id,name,slug', 'user:id,name,username', 'user.profile:user_id,avatar_hash', + 'group:id,name,slug,avatar_path', ]) ->get() ->keyBy('id'); @@ -268,6 +269,14 @@ final class SimilarArtworksPageController extends Controller 'sort' => ['trending_score_7d:desc', 'created_at:desc'], ])->paginate(self::PER_PAGE, 'page', $page); + $results->getCollection()->load([ + 'categories:id,slug,name,content_type_id', + 'categories.contentType:id,name,slug', + 'user:id,name,username', + 'user.profile:user_id,avatar_hash', + 'group:id,name,slug,avatar_path', + ]); + $results->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); return $results; diff --git a/app/Http/Controllers/Web/TagController.php b/app/Http/Controllers/Web/TagController.php index 7b6c7868..8f2e77d9 100644 --- a/app/Http/Controllers/Web/TagController.php +++ b/app/Http/Controllers/Web/TagController.php @@ -51,7 +51,7 @@ final class TagController extends Controller $artworks = $this->search->byTag($tag->slug, $perPage, $sort); // Eager-load relations used by the gallery presenter and thumbnails. - $artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories'])); + $artworks->getCollection()->loadMissing(['user.profile', 'categories.contentType']); // Sidebar: main content type links (same as browse gallery) $mainCategories = ContentType::ordered()->where('hide_from_menu', false)->get(['name', 'slug']) diff --git a/app/Http/Controllers/Web/WorldWebStoryController.php b/app/Http/Controllers/Web/WorldWebStoryController.php new file mode 100644 index 00000000..17d190ba --- /dev/null +++ b/app/Http/Controllers/Web/WorldWebStoryController.php @@ -0,0 +1,52 @@ + WorldWebStory::query() + ->with('world') + ->visible() + ->orderByDesc('featured') + ->orderByDesc('published_at') + ->paginate(12) + ->withQueryString()); + + return view('web-stories.index', [ + 'stories' => $stories, + 'seo' => $this->seo->indexSeo(), + 'useUnifiedSeo' => true, + ]); + } + + public function show(string $slug): View + { + $story = Cache::remember('web_story:' . $slug, 300, fn () => WorldWebStory::query() + ->with(['world', 'orderedPages.artwork.user']) + ->visible() + ->where('slug', $slug) + ->first()); + + abort_unless($story instanceof WorldWebStory, 404); + + return view('web-stories.show', [ + 'story' => $story, + 'meta' => $this->seo->storyMeta($story), + ]); + } +} \ No newline at end of file diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 367645d5..e02c24da 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -94,6 +94,9 @@ final class HandleInertiaRequests extends Middleware { $canReadSessionAuth = $this->canReadSessionAuth($request); $user = $canReadSessionAuth ? $request->user() : null; + $sessionFlash = static fn (string $key): ?string => $canReadSessionAuth + ? $request->session()->get($key) + : null; return array_merge(parent::share($request), [ 'auth' => [ @@ -108,6 +111,11 @@ final class HandleInertiaRequests extends Middleware 'is_moderator' => $user->isModerator(), ] : null, ], + 'flash' => [ + 'success' => fn (): ?string => $sessionFlash('success'), + 'error' => fn (): ?string => $sessionFlash('error'), + 'warning' => fn (): ?string => $sessionFlash('warning'), + ], 'cdn' => [ 'files_url' => config('cdn.files_url'), ], diff --git a/app/Http/Requests/Academy/UpsertAcademyLessonRequest.php b/app/Http/Requests/Academy/UpsertAcademyLessonRequest.php index 793f3c4e..466444b8 100644 --- a/app/Http/Requests/Academy/UpsertAcademyLessonRequest.php +++ b/app/Http/Requests/Academy/UpsertAcademyLessonRequest.php @@ -111,7 +111,7 @@ class UpsertAcademyLessonRequest extends FormRequest 'cover_image' => ['nullable', 'string', 'max:2048'], 'article_cover_image' => ['nullable', 'string', 'max:2048'], 'tags' => ['nullable', 'array'], - 'tags.*' => ['string', 'max:60'], + 'tags.*' => ['string', 'max:100'], 'video_url' => ['nullable', 'string', 'max:2048'], 'reading_minutes' => ['required', 'integer', 'min:1', 'max:999'], 'featured' => ['required', 'boolean'], diff --git a/app/Http/Requests/Academy/UpsertAcademyPromptTemplateRequest.php b/app/Http/Requests/Academy/UpsertAcademyPromptTemplateRequest.php index 3226b82b..ad4e500b 100644 --- a/app/Http/Requests/Academy/UpsertAcademyPromptTemplateRequest.php +++ b/app/Http/Requests/Academy/UpsertAcademyPromptTemplateRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Requests\Academy; +use JsonException; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -22,6 +23,10 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest 'active' => $this->boolean('active', true), 'new_category_name' => trim((string) $this->input('new_category_name', '')), 'tags' => array_values(array_filter((array) $this->input('tags', []))), + 'documentation' => $this->normalizeDocumentation($this->input('documentation')), + 'placeholders' => $this->normalizePlaceholders($this->input('placeholders')), + 'helper_prompts' => $this->normalizeHelperPrompts($this->input('helper_prompts')), + 'prompt_variants' => $this->normalizePromptVariants($this->input('prompt_variants')), 'tool_notes' => collect($this->input('tool_notes', [])) ->filter(static fn ($note): bool => is_array($note) || is_string($note)) ->map(function ($note): array|string { @@ -30,6 +35,7 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest } return [ + 'display_type' => $note['display_type'] ?? null, 'provider' => $note['provider'] ?? null, 'model_name' => $note['model_name'] ?? null, 'notes' => $note['notes'] ?? null, @@ -62,12 +68,57 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest 'negative_prompt' => ['nullable', 'string'], 'usage_notes' => ['nullable', 'string'], 'workflow_notes' => ['nullable', 'string'], + 'documentation' => ['nullable', 'array'], + 'documentation.summary' => ['nullable', 'string'], + 'documentation.best_for' => ['nullable', 'array'], + 'documentation.best_for.*' => ['nullable', 'string'], + 'documentation.how_to_use' => ['nullable', 'array'], + 'documentation.how_to_use.*' => ['nullable', 'string'], + 'documentation.required_inputs' => ['nullable', 'array'], + 'documentation.required_inputs.*' => ['nullable', 'string'], + 'documentation.workflow' => ['nullable', 'array'], + 'documentation.workflow.*' => ['nullable', 'string'], + 'documentation.tips' => ['nullable', 'array'], + 'documentation.tips.*' => ['nullable', 'string'], + 'documentation.common_mistakes' => ['nullable', 'array'], + 'documentation.common_mistakes.*' => ['nullable', 'string'], + 'documentation.data_accuracy_notes' => ['nullable', 'array'], + 'documentation.data_accuracy_notes.*' => ['nullable', 'string'], + 'documentation.display_notes' => ['nullable', 'string'], + 'placeholders' => ['nullable', 'array'], + 'placeholders.*.key' => ['nullable', 'string', 'max:120'], + 'placeholders.*.label' => ['nullable', 'string', 'max:180'], + 'placeholders.*.description' => ['nullable', 'string'], + 'placeholders.*.required' => ['nullable', 'boolean'], + 'placeholders.*.example' => ['nullable'], + 'placeholders.*.default' => ['nullable'], + 'placeholders.*.type' => ['nullable', 'string', 'max:120'], + 'helper_prompts' => ['nullable', 'array'], + 'helper_prompts.*.title' => ['required_with:helper_prompts', 'string', 'max:180'], + 'helper_prompts.*.type' => ['nullable', 'string', Rule::in(['data_collection', 'prompt_preparation', 'refinement', 'validation', 'variation', 'translation', 'seo', 'other'])], + 'helper_prompts.*.description' => ['nullable', 'string'], + 'helper_prompts.*.prompt' => ['required_with:helper_prompts', 'string'], + 'helper_prompts.*.expected_output' => ['nullable', 'string', Rule::in(['json', 'text', 'markdown', 'image_prompt'])], + 'helper_prompts.*.active' => ['nullable', 'boolean'], + 'prompt_variants' => ['nullable', 'array'], + 'prompt_variants.*.title' => ['required_with:prompt_variants', 'string', 'max:180'], + 'prompt_variants.*.slug' => ['nullable', 'string', 'max:180'], + 'prompt_variants.*.description' => ['nullable', 'string'], + 'prompt_variants.*.prompt' => ['required_with:prompt_variants', 'string'], + 'prompt_variants.*.negative_prompt' => ['nullable', 'string'], + 'prompt_variants.*.recommended' => ['nullable', 'boolean'], + 'prompt_variants.*.recommended_for' => ['nullable', 'array'], + 'prompt_variants.*.recommended_for.*' => ['nullable', 'string'], + 'prompt_variants.*.risk_notes' => ['nullable', 'array'], + 'prompt_variants.*.risk_notes.*' => ['nullable', 'string'], + 'prompt_variants.*.active' => ['nullable', 'boolean'], 'difficulty' => ['required', 'string', Rule::in((array) config('academy.difficulty_levels', []))], 'access_level' => ['required', 'string', Rule::in(['free', 'creator', 'pro'])], 'aspect_ratio' => ['nullable', 'string', 'max:20'], 'tags' => ['nullable', 'array'], 'tags.*' => ['string', 'max:60'], 'tool_notes' => ['nullable', 'array'], + 'tool_notes.*.display_type' => ['nullable', 'string', 'max:50'], 'tool_notes.*.provider' => ['nullable', 'string', 'max:100'], 'tool_notes.*.model_name' => ['nullable', 'string', 'max:150'], 'tool_notes.*.notes' => ['nullable', 'string'], @@ -89,4 +140,251 @@ class UpsertAcademyPromptTemplateRequest extends FormRequest 'seo_description' => ['nullable', 'string', 'max:255'], ]; } + + private function decodeStructuredInput(mixed $value): mixed + { + if (! is_string($value)) { + return $value; + } + + $trimmed = trim($value); + + if ($trimmed === '') { + return null; + } + + try { + return json_decode($trimmed, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return $value; + } + } + + private function normalizeDocumentation(mixed $value): mixed + { + $value = $this->decodeStructuredInput($value); + + if ($value === null) { + return null; + } + + if (! is_array($value)) { + return $value; + } + + $listFields = ['best_for', 'how_to_use', 'required_inputs', 'workflow', 'tips', 'common_mistakes', 'data_accuracy_notes']; + $documentation = [ + 'summary' => $this->normalizeOptionalString($value['summary'] ?? null), + 'display_notes' => $this->normalizeOptionalString($value['display_notes'] ?? null), + ]; + + foreach ($listFields as $field) { + $documentation[$field] = $this->normalizeStringList($value[$field] ?? []); + } + + $hasContent = $documentation['summary'] !== null + || $documentation['display_notes'] !== null + || collect($listFields)->contains(fn (string $field): bool => $documentation[$field] !== []); + + return $hasContent ? $documentation : null; + } + + private function normalizePlaceholders(mixed $value): mixed + { + $value = $this->decodeStructuredInput($value); + + if ($value === null) { + return []; + } + + if (! is_array($value)) { + return $value; + } + + $value = $this->normalizeStructuredObjectList($value, ['key', 'label', 'description', 'required', 'example', 'default', 'type']); + + return collect($value) + ->values() + ->map(function ($placeholder): mixed { + if (! is_array($placeholder)) { + return $placeholder; + } + + return [ + 'key' => $this->normalizeOptionalString($placeholder['key'] ?? null), + 'label' => $this->normalizeOptionalString($placeholder['label'] ?? null), + 'description' => $this->normalizeOptionalString($placeholder['description'] ?? null), + 'required' => filter_var($placeholder['required'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false, + 'example' => $this->normalizeJsonScalar($placeholder['example'] ?? null), + 'default' => $this->normalizeJsonScalar($placeholder['default'] ?? null), + 'type' => $this->normalizeOptionalString($placeholder['type'] ?? null), + ]; + }) + ->filter(function ($placeholder): bool { + if (! is_array($placeholder)) { + return true; + } + + return collect([ + $placeholder['key'] ?? null, + $placeholder['label'] ?? null, + $placeholder['description'] ?? null, + $placeholder['example'] ?? null, + $placeholder['default'] ?? null, + $placeholder['type'] ?? null, + ])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []); + }) + ->values() + ->all(); + } + + private function normalizeHelperPrompts(mixed $value): mixed + { + $value = $this->decodeStructuredInput($value); + + if ($value === null) { + return []; + } + + if (! is_array($value)) { + return $value; + } + + $value = $this->normalizeStructuredObjectList($value, ['title', 'type', 'description', 'prompt', 'expected_output', 'active']); + + return collect($value) + ->values() + ->map(function ($helperPrompt): mixed { + if (! is_array($helperPrompt)) { + return $helperPrompt; + } + + return [ + 'title' => $this->normalizeOptionalString($helperPrompt['title'] ?? null), + 'type' => $this->normalizeOptionalString($helperPrompt['type'] ?? null) ?? 'other', + 'description' => $this->normalizeOptionalString($helperPrompt['description'] ?? null), + 'prompt' => $this->normalizeOptionalString($helperPrompt['prompt'] ?? null), + 'expected_output' => $this->normalizeOptionalString($helperPrompt['expected_output'] ?? null) ?? 'text', + 'active' => filter_var($helperPrompt['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true, + ]; + }) + ->filter(function ($helperPrompt): bool { + if (! is_array($helperPrompt)) { + return true; + } + + return collect([ + $helperPrompt['title'] ?? null, + $helperPrompt['description'] ?? null, + $helperPrompt['prompt'] ?? null, + ])->contains(fn ($item): bool => $item !== null && $item !== ''); + }) + ->values() + ->all(); + } + + private function normalizePromptVariants(mixed $value): mixed + { + $value = $this->decodeStructuredInput($value); + + if ($value === null) { + return []; + } + + if (! is_array($value)) { + return $value; + } + + $value = $this->normalizeStructuredObjectList($value, ['title', 'slug', 'description', 'prompt', 'negative_prompt', 'recommended', 'recommended_for', 'risk_notes', 'active']); + + return collect($value) + ->values() + ->map(function ($variant): mixed { + if (! is_array($variant)) { + return $variant; + } + + return [ + 'title' => $this->normalizeOptionalString($variant['title'] ?? null), + 'slug' => $this->normalizeOptionalString($variant['slug'] ?? null), + 'description' => $this->normalizeOptionalString($variant['description'] ?? null), + 'prompt' => $this->normalizeOptionalString($variant['prompt'] ?? null), + 'negative_prompt' => $this->normalizeOptionalString($variant['negative_prompt'] ?? null), + 'recommended' => filter_var($variant['recommended'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false, + 'recommended_for' => $this->normalizeStringList($variant['recommended_for'] ?? []), + 'risk_notes' => $this->normalizeStringList($variant['risk_notes'] ?? []), + 'active' => filter_var($variant['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true, + ]; + }) + ->filter(function ($variant): bool { + if (! is_array($variant)) { + return true; + } + + return collect([ + $variant['title'] ?? null, + $variant['description'] ?? null, + $variant['prompt'] ?? null, + $variant['negative_prompt'] ?? null, + ])->contains(fn ($item): bool => $item !== null && $item !== ''); + }) + ->values() + ->all(); + } + + private function normalizeStringList(mixed $value): array + { + if (! is_array($value)) { + $value = $value === null ? [] : [$value]; + } + + return collect($value) + ->map(fn ($item): string => trim((string) $item)) + ->filter(static fn (string $item): bool => $item !== '') + ->values() + ->all(); + } + + private function normalizeOptionalString(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $normalized = trim((string) $value); + + return $normalized !== '' ? $normalized : null; + } + + private function normalizeJsonScalar(mixed $value): mixed + { + if (! is_string($value)) { + return $value; + } + + $trimmed = trim($value); + + return $trimmed !== '' ? $trimmed : null; + } + + /** + * @param array $value + * @param array $expectedKeys + * @return array + */ + private function normalizeStructuredObjectList(array $value, array $expectedKeys): array + { + if (array_is_list($value)) { + return $value; + } + + $keys = array_keys($value); + $normalizedKeys = array_map(static fn ($key): string => (string) $key, $keys); + + if ($normalizedKeys === [] || array_intersect($normalizedKeys, $expectedKeys) === []) { + return $value; + } + + return [$value]; + } } \ No newline at end of file diff --git a/app/Listeners/Academy/HandleAcademyStripeWebhook.php b/app/Listeners/Academy/HandleAcademyStripeWebhook.php new file mode 100644 index 00000000..c601dbf2 --- /dev/null +++ b/app/Listeners/Academy/HandleAcademyStripeWebhook.php @@ -0,0 +1,20 @@ +audit->recordReceived($event->payload); + } +} \ No newline at end of file diff --git a/app/Listeners/Academy/HandleAcademyStripeWebhookHandled.php b/app/Listeners/Academy/HandleAcademyStripeWebhookHandled.php new file mode 100644 index 00000000..354108b2 --- /dev/null +++ b/app/Listeners/Academy/HandleAcademyStripeWebhookHandled.php @@ -0,0 +1,20 @@ +audit->recordHandled($event->payload); + } +} \ No newline at end of file diff --git a/app/Models/AcademyBillingEvent.php b/app/Models/AcademyBillingEvent.php new file mode 100644 index 00000000..3e3aae18 --- /dev/null +++ b/app/Models/AcademyBillingEvent.php @@ -0,0 +1,45 @@ + + */ + protected $fillable = [ + 'user_id', + 'stripe_event_id', + 'stripe_customer_id', + 'stripe_subscription_id', + 'event_type', + 'academy_tier', + 'academy_plan', + 'payload_summary', + 'processed_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'payload_summary' => 'array', + 'processed_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} \ No newline at end of file diff --git a/app/Models/AcademyContentMetricDaily.php b/app/Models/AcademyContentMetricDaily.php new file mode 100644 index 00000000..5116e630 --- /dev/null +++ b/app/Models/AcademyContentMetricDaily.php @@ -0,0 +1,47 @@ + 'date', + 'popularity_score' => 'decimal:2', + 'conversion_score' => 'decimal:2', + ]; +} \ No newline at end of file diff --git a/app/Models/AcademyEvent.php b/app/Models/AcademyEvent.php new file mode 100644 index 00000000..598c54d9 --- /dev/null +++ b/app/Models/AcademyEvent.php @@ -0,0 +1,54 @@ + 'array', + 'occurred_at' => 'datetime', + 'is_logged_in' => 'boolean', + 'is_subscriber' => 'boolean', + 'is_admin' => 'boolean', + 'is_bot' => 'boolean', + 'is_crawler' => 'boolean', + 'is_suspicious' => 'boolean', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} \ No newline at end of file diff --git a/app/Models/AcademyLike.php b/app/Models/AcademyLike.php new file mode 100644 index 00000000..d6c5c8e2 --- /dev/null +++ b/app/Models/AcademyLike.php @@ -0,0 +1,22 @@ +belongsTo(User::class, 'user_id'); + } +} \ No newline at end of file diff --git a/app/Models/AcademyPromptTemplate.php b/app/Models/AcademyPromptTemplate.php index cf032a25..9bd9b877 100644 --- a/app/Models/AcademyPromptTemplate.php +++ b/app/Models/AcademyPromptTemplate.php @@ -24,6 +24,10 @@ class AcademyPromptTemplate extends Model 'negative_prompt', 'usage_notes', 'workflow_notes', + 'documentation', + 'placeholders', + 'helper_prompts', + 'prompt_variants', 'difficulty', 'access_level', 'aspect_ratio', @@ -41,6 +45,10 @@ class AcademyPromptTemplate extends Model protected $casts = [ 'tags' => 'array', 'tool_notes' => 'array', + 'documentation' => 'array', + 'placeholders' => 'array', + 'helper_prompts' => 'array', + 'prompt_variants' => 'array', 'featured' => 'boolean', 'prompt_of_week' => 'boolean', 'active' => 'boolean', diff --git a/app/Models/AcademySave.php b/app/Models/AcademySave.php new file mode 100644 index 00000000..0ab0f880 --- /dev/null +++ b/app/Models/AcademySave.php @@ -0,0 +1,22 @@ +belongsTo(User::class, 'user_id'); + } +} \ No newline at end of file diff --git a/app/Models/AcademySearchLog.php b/app/Models/AcademySearchLog.php new file mode 100644 index 00000000..11782593 --- /dev/null +++ b/app/Models/AcademySearchLog.php @@ -0,0 +1,37 @@ + 'array', + 'is_logged_in' => 'boolean', + 'is_subscriber' => 'boolean', + 'is_bot' => 'boolean', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} \ No newline at end of file diff --git a/app/Models/AcademyUserProgress.php b/app/Models/AcademyUserProgress.php new file mode 100644 index 00000000..77d03ebc --- /dev/null +++ b/app/Models/AcademyUserProgress.php @@ -0,0 +1,47 @@ + 'array', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'last_seen_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function course(): BelongsTo + { + return $this->belongsTo(AcademyCourse::class, 'course_id'); + } + + public function lesson(): BelongsTo + { + return $this->belongsTo(AcademyLesson::class, 'lesson_id'); + } +} \ No newline at end of file diff --git a/app/Models/User.php b/app/Models/User.php index 0348a792..e3969880 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -18,8 +18,13 @@ use App\Models\ConversationParticipant; use App\Models\AcademyBadge; use App\Models\AcademyCourseEnrollment; use App\Models\AcademyChallengeSubmission; +use App\Models\AcademyEvent; +use App\Models\AcademyLike; use App\Models\AcademyLessonProgress; +use App\Models\AcademySave; use App\Models\AcademySavedPrompt; +use App\Models\AcademySearchLog; +use App\Models\AcademyUserProgress; use App\Models\Message; use App\Models\Notification; use App\Models\Achievement; @@ -30,6 +35,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Facades\DB; +use Laravel\Cashier\Billable; use Laravel\Scout\Searchable; class User extends Authenticatable @@ -40,7 +46,7 @@ class User extends Authenticatable ]; /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable, SoftDeletes; + use Billable, HasFactory, Notifiable, SoftDeletes; use Searchable { Searchable::bootSearchable as private bootScoutSearchable; } @@ -218,6 +224,31 @@ class User extends Authenticatable return $this->hasMany(AcademySavedPrompt::class, 'user_id'); } + public function academyEvents(): HasMany + { + return $this->hasMany(AcademyEvent::class, 'user_id'); + } + + public function academyLikes(): HasMany + { + return $this->hasMany(AcademyLike::class, 'user_id'); + } + + public function academySaves(): HasMany + { + return $this->hasMany(AcademySave::class, 'user_id'); + } + + public function academyUserProgress(): HasMany + { + return $this->hasMany(AcademyUserProgress::class, 'user_id'); + } + + public function academySearchLogs(): HasMany + { + return $this->hasMany(AcademySearchLog::class, 'user_id'); + } + public function academyChallengeSubmissions(): HasMany { return $this->hasMany(AcademyChallengeSubmission::class, 'user_id'); @@ -448,12 +479,12 @@ class User extends Authenticatable public function hasAcademyCreatorAccess(): bool { - return $this->hasAcademyProAccess() || strtolower(trim((string) ($this->role ?? ''))) === 'academy_creator'; + return in_array(app(\App\Services\Academy\AcademyAccessService::class)->currentTier($this), ['creator', 'pro', 'admin'], true); } public function hasAcademyProAccess(): bool { - return strtolower(trim((string) ($this->role ?? ''))) === 'academy_pro'; + return in_array(app(\App\Services\Academy\AcademyAccessService::class)->currentTier($this), ['pro', 'admin'], true); } public function canAccessAcademyContent(object|array $content): bool diff --git a/app/Models/World.php b/app/Models/World.php index 25c5f231..dc6effd3 100644 --- a/app/Models/World.php +++ b/app/Models/World.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Carbon; @@ -197,6 +198,16 @@ class World extends Model return $this->hasMany(WorldRewardGrant::class)->orderByDesc('granted_at')->orderByDesc('id'); } + public function webStories(): HasMany + { + return $this->hasMany(WorldWebStory::class)->orderByDesc('published_at')->orderByDesc('id'); + } + + public function publishedWebStory(): HasOne + { + return $this->hasOne(WorldWebStory::class)->visible()->latest('published_at')->latest('id'); + } + public function scopePublished(Builder $query): Builder { return $query diff --git a/app/Models/WorldWebStory.php b/app/Models/WorldWebStory.php new file mode 100644 index 00000000..9b3168c1 --- /dev/null +++ b/app/Models/WorldWebStory.php @@ -0,0 +1,181 @@ + 'boolean', + 'active' => 'boolean', + 'noindex' => 'boolean', + 'published_at' => 'datetime', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + ]; + + protected static function booted(): void + { + $flushCache = static function (self $story): void { + Cache::forget('web_story_index'); + Cache::forget('web_story:' . $story->slug); + + if ($story->world?->slug) { + Cache::forget('world:' . $story->world->slug . ':web_story'); + } elseif ($story->world_id) { + $worldSlug = World::query()->whereKey($story->world_id)->value('slug'); + if (is_string($worldSlug) && $worldSlug !== '') { + Cache::forget('world:' . $worldSlug . ':web_story'); + } + } + }; + + static::saved($flushCache); + static::deleted($flushCache); + static::restored($flushCache); + } + + public function world(): BelongsTo + { + return $this->belongsTo(World::class); + } + + public function pages(): HasMany + { + return $this->hasMany(WorldWebStoryPage::class, 'story_id')->orderedPages(); + } + + public function orderedPages(): HasMany + { + return $this->hasMany(WorldWebStoryPage::class, 'story_id')->orderedPages(); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('status', self::STATUS_PUBLISHED); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('active', true); + } + + public function scopeFeatured(Builder $query): Builder + { + return $query->where('featured', true); + } + + public function scopeVisible(Builder $query): Builder + { + return $query + ->active() + ->published() + ->where('noindex', false) + ->where(function (Builder $builder): void { + $builder->whereNull('published_at') + ->orWhere('published_at', '<=', now()); + }) + ->where(function (Builder $builder): void { + $builder->whereNull('starts_at') + ->orWhere('starts_at', '<=', now()); + }) + ->where(function (Builder $builder): void { + $builder->whereNull('ends_at') + ->orWhere('ends_at', '>=', now()); + }); + } + + public function publicUrl(): string + { + return route('web-stories.show', ['slug' => $this->slug]); + } + + public function posterPortraitUrl(): ?string + { + return $this->assetUrl($this->poster_portrait_path); + } + + public function posterSquareUrl(): ?string + { + return $this->assetUrl($this->poster_square_path); + } + + public function publisherLogoUrl(): ?string + { + return $this->assetUrl($this->publisher_logo_path); + } + + public function seoTitle(): string + { + return trim((string) ($this->seo_title ?: $this->title)); + } + + public function seoDescription(): string + { + return trim((string) ($this->seo_description ?: $this->excerpt ?: $this->description ?: '')); + } + + private function assetUrl(?string $path): ?string + { + $resolved = trim((string) $path); + + if ($resolved === '') { + return null; + } + + if (str_starts_with($resolved, 'http://') || str_starts_with($resolved, 'https://')) { + return $resolved; + } + + return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($resolved, '/'); + } +} \ No newline at end of file diff --git a/app/Models/WorldWebStoryPage.php b/app/Models/WorldWebStoryPage.php new file mode 100644 index 00000000..641b4470 --- /dev/null +++ b/app/Models/WorldWebStoryPage.php @@ -0,0 +1,118 @@ + 'integer', + 'overlay_strength' => 'integer', + 'active' => 'boolean', + ]; + + protected static function booted(): void + { + $flushCache = static function (self $page): void { + $story = $page->relationLoaded('story') ? $page->story : $page->story()->with('world')->first(); + + if (! ($story instanceof WorldWebStory)) { + return; + } + + Cache::forget('web_story:' . $story->slug); + Cache::forget('web_story_index'); + + if ($story->world?->slug) { + Cache::forget('world:' . $story->world->slug . ':web_story'); + } + }; + + static::saved($flushCache); + static::deleted($flushCache); + static::restored($flushCache); + } + + public function story(): BelongsTo + { + return $this->belongsTo(WorldWebStory::class, 'story_id'); + } + + public function artwork(): BelongsTo + { + return $this->belongsTo(Artwork::class); + } + + public function scopeOrderedPages(Builder $query): Builder + { + return $query->orderBy('position')->orderBy('id'); + } + + public function backgroundUrl(): ?string + { + return $this->assetUrl($this->background_mobile_path ?: $this->background_path); + } + + public function desktopBackgroundUrl(): ?string + { + return $this->assetUrl($this->background_path ?: $this->background_mobile_path); + } + + private function assetUrl(?string $path): ?string + { + $resolved = trim((string) $path); + + if ($resolved === '') { + return null; + } + + if (str_starts_with($resolved, 'http://') || str_starts_with($resolved, 'https://')) { + return $resolved; + } + + return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($resolved, '/'); + } +} \ No newline at end of file diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d26099ad..fc2398e5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -46,6 +46,10 @@ use App\Services\Images\Detectors\HeuristicSubjectDetector; use App\Services\Images\Detectors\NullSubjectDetector; use App\Services\Images\Detectors\VisionSubjectDetector; use Klevze\ControlPanel\Framework\Core\Menu; +use Laravel\Cashier\Events\WebhookHandled; +use Laravel\Cashier\Events\WebhookReceived; +use App\Listeners\Academy\HandleAcademyStripeWebhook; +use App\Listeners\Academy\HandleAcademyStripeWebhookHandled; class AppServiceProvider extends ServiceProvider { @@ -154,6 +158,14 @@ class AppServiceProvider extends ServiceProvider \App\Events\Achievements\UserXpUpdated::class, \App\Listeners\Achievements\CheckUserAchievements::class, ); + Event::listen( + WebhookReceived::class, + HandleAcademyStripeWebhook::class, + ); + Event::listen( + WebhookHandled::class, + HandleAcademyStripeWebhookHandled::class, + ); // Provide toolbar counts and user info to layout views (port of legacy toolbar logic) View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) { diff --git a/app/Services/Academy/AcademyAccessService.php b/app/Services/Academy/AcademyAccessService.php index 1712644d..db61e905 100644 --- a/app/Services/Academy/AcademyAccessService.php +++ b/app/Services/Academy/AcademyAccessService.php @@ -15,11 +15,39 @@ use App\Models\AcademyPromptTemplate; use App\Models\User; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use Laravel\Cashier\Subscription; final class AcademyAccessService { + /** + * @var array + */ + private array $assetExistsCache = []; + + /** + * @var array + */ + private array $paidTierCache = []; + + /** + * @var array + */ + private array $subscriptionCache = []; + + /** + * @var array|null + */ + private ?array $priceTierMap = null; + + public function canAccess(?User $user, string $requiredLevel): bool + { + return $this->canAccessContent($user, $requiredLevel); + } + public function canAccessContent(?User $user, string $accessLevel): bool { + $accessLevel = $this->normalizeAccessLevel($accessLevel); + if ($accessLevel === 'free') { return true; } @@ -28,11 +56,40 @@ final class AcademyAccessService return false; } - if ($user->isAdmin()) { - return true; + return $this->rankForLevel($this->currentTier($user)) >= $this->rankForLevel($accessLevel); + } + + public function currentTier(?User $user): string + { + if (! $user instanceof User) { + return 'free'; } - return $this->rankForUser($user) >= $this->rankForLevel($accessLevel); + if ($this->isAcademyAdmin($user)) { + return 'admin'; + } + + return $this->paidTier($user) ?? 'free'; + } + + public function paidTier(?User $user): ?string + { + if (! $user instanceof User) { + return null; + } + + $cacheKey = (int) $user->getKey(); + + if (array_key_exists($cacheKey, $this->paidTierCache)) { + return $this->paidTierCache[$cacheKey]; + } + + return $this->paidTierCache[$cacheKey] = $this->resolveSubscriptionTier($user) ?? $this->resolveLegacyPaidTier($user); + } + + public function hasActiveAcademySubscription(User $user): bool + { + return $this->activeAcademySubscription($user) instanceof Subscription; } public function canAccessLesson(?User $user, AcademyLesson $lesson): bool @@ -59,11 +116,7 @@ final class AcademyAccessService { $accessLevel = trim((string) ($courseLesson->access_override ?: $courseLesson->lesson?->access_level ?: 'free')); - if ($accessLevel === 'premium') { - return $user?->isAdmin() ?? false; - } - - return $this->canAccessContent($user, $accessLevel === 'mixed' ? 'free' : $accessLevel); + return $this->canAccessContent($user, $accessLevel); } public function lessonPayload(AcademyLesson $lesson, ?User $viewer, bool $includeFull = false, ?bool $authorizedOverride = null): array @@ -172,6 +225,19 @@ final class AcademyAccessService public function promptPayload(AcademyPromptTemplate $prompt, ?User $viewer, bool $includeFull = false): array { $authorized = $this->canAccessPrompt($viewer, $prompt); + $publicExamples = $this->promptPublicExamplesPayload($prompt, (array) ($prompt->tool_notes ?? [])); + $previewImage = $this->promptPreviewImagePayload((string) ($prompt->preview_image ?? '')); + $documentation = $this->promptDocumentationPayload($prompt->documentation); + $placeholders = $this->promptPlaceholdersPayload((array) ($prompt->placeholders ?? [])); + $hasPlaceholderInputs = $this->promptHasPlaceholderInputs((string) $prompt->prompt, $placeholders); + $hasHelperPrompts = $this->promptHelperPromptsPayload((array) ($prompt->helper_prompts ?? [])) !== []; + $hasPromptVariants = $this->promptVariantsPayload((array) ($prompt->prompt_variants ?? [])) !== []; + $helperPrompts = $authorized && $includeFull + ? $this->promptHelperPromptsPayload((array) ($prompt->helper_prompts ?? [])) + : []; + $promptVariants = $authorized && $includeFull + ? $this->promptVariantsPayload((array) ($prompt->prompt_variants ?? [])) + : []; return [ 'id' => (int) $prompt->id, @@ -183,12 +249,25 @@ final class AcademyAccessService 'usage_notes' => ($authorized && $includeFull) ? (string) ($prompt->usage_notes ?? '') : null, 'workflow_notes' => ($authorized && $includeFull) ? (string) ($prompt->workflow_notes ?? '') : null, 'prompt_preview' => $authorized ? null : $this->previewText((string) $prompt->prompt, 220), + 'documentation' => $documentation, + 'placeholders' => $placeholders, + 'has_placeholder_inputs' => $hasPlaceholderInputs, + 'has_helper_prompts' => $hasHelperPrompts, + 'has_prompt_variants' => $hasPromptVariants, + 'helper_prompts' => $helperPrompts, + 'prompt_variants' => $promptVariants, 'difficulty' => (string) $prompt->difficulty, 'access_level' => (string) $prompt->access_level, + 'access_requirement' => $this->promptAccessRequirement((string) $prompt->access_level), + 'unlock_heading' => $this->promptUnlockHeading((string) $prompt->access_level), + 'unlock_description' => $this->promptUnlockDescription((string) $prompt->access_level), 'aspect_ratio' => $prompt->aspect_ratio, 'tags' => array_values((array) ($prompt->tags ?? [])), + 'public_examples' => $publicExamples, 'tool_notes' => $authorized ? $this->promptToolNotesPayload((array) ($prompt->tool_notes ?? [])) : [], - 'preview_image' => $this->resolvePreviewImageUrl((string) ($prompt->preview_image ?? '')), + 'preview_image' => $previewImage['url'], + 'preview_image_thumb' => $previewImage['thumb_url'], + 'preview_image_srcset' => $previewImage['srcset'], 'featured' => (bool) $prompt->featured, 'prompt_of_week' => (bool) $prompt->prompt_of_week, 'published_at' => $prompt->published_at?->toISOString(), @@ -202,6 +281,235 @@ final class AcademyAccessService ]; } + /** + * @param mixed $documentation + * @return array + */ + private function promptDocumentationPayload(mixed $documentation): array + { + $normalized = is_array($documentation) ? $documentation : []; + $listFields = ['best_for', 'how_to_use', 'required_inputs', 'workflow', 'tips', 'common_mistakes', 'data_accuracy_notes']; + $payload = [ + 'summary' => $this->nullableTrimmedString($normalized['summary'] ?? null), + 'display_notes' => $this->nullableTrimmedString($normalized['display_notes'] ?? null), + ]; + + foreach ($listFields as $field) { + $payload[$field] = $this->normalizeStringList($normalized[$field] ?? []); + } + + return $payload; + } + + /** + * @param array $placeholders + * @return array> + */ + private function promptPlaceholdersPayload(array $placeholders): array + { + return collect($placeholders) + ->filter(static fn ($placeholder): bool => is_array($placeholder)) + ->map(function (array $placeholder): array { + return [ + 'key' => trim((string) ($placeholder['key'] ?? '')), + 'label' => $this->nullableTrimmedString($placeholder['label'] ?? null), + 'description' => $this->nullableTrimmedString($placeholder['description'] ?? null), + 'required' => filter_var($placeholder['required'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false, + 'example' => $placeholder['example'] ?? null, + 'default' => $placeholder['default'] ?? null, + 'type' => $this->nullableTrimmedString($placeholder['type'] ?? null), + ]; + }) + ->filter(function (array $placeholder): bool { + return collect([ + $placeholder['key'], + $placeholder['label'], + $placeholder['description'], + $placeholder['example'], + $placeholder['default'], + $placeholder['type'], + ])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []); + }) + ->values() + ->all(); + } + + /** + * @param array> $placeholders + */ + private function promptHasPlaceholderInputs(string $prompt, array $placeholders): bool + { + if ($prompt === '' || $placeholders === []) { + return false; + } + + foreach ($placeholders as $placeholder) { + $key = trim((string) ($placeholder['key'] ?? '')); + + if ($key === '') { + continue; + } + + if (mb_stripos($prompt, '['.$key.']') !== false) { + return true; + } + } + + return false; + } + + /** + * @param array $helperPrompts + * @return array> + */ + private function promptHelperPromptsPayload(array $helperPrompts): array + { + return collect($helperPrompts) + ->filter(static fn ($helperPrompt): bool => is_array($helperPrompt)) + ->map(function (array $helperPrompt): array { + return [ + 'title' => trim((string) ($helperPrompt['title'] ?? '')), + 'type' => trim((string) ($helperPrompt['type'] ?? 'other')) ?: 'other', + 'description' => $this->nullableTrimmedString($helperPrompt['description'] ?? null), + 'prompt' => trim((string) ($helperPrompt['prompt'] ?? '')), + 'expected_output' => trim((string) ($helperPrompt['expected_output'] ?? 'text')) ?: 'text', + 'active' => filter_var($helperPrompt['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true, + ]; + }) + ->filter(function (array $helperPrompt): bool { + return $helperPrompt['active'] !== false + && collect([ + $helperPrompt['title'], + $helperPrompt['description'], + $helperPrompt['prompt'], + ])->contains(fn ($item): bool => $item !== null && $item !== ''); + }) + ->values() + ->all(); + } + + /** + * @param array $variants + * @return array> + */ + private function promptVariantsPayload(array $variants): array + { + return collect($variants) + ->filter(static fn ($variant): bool => is_array($variant)) + ->map(function (array $variant): array { + return [ + 'title' => trim((string) ($variant['title'] ?? '')), + 'slug' => $this->nullableTrimmedString($variant['slug'] ?? null), + 'description' => $this->nullableTrimmedString($variant['description'] ?? null), + 'prompt' => trim((string) ($variant['prompt'] ?? '')), + 'negative_prompt' => $this->nullableTrimmedString($variant['negative_prompt'] ?? null), + 'recommended' => filter_var($variant['recommended'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false, + 'recommended_for' => $this->normalizeStringList($variant['recommended_for'] ?? []), + 'risk_notes' => $this->normalizeStringList($variant['risk_notes'] ?? []), + 'active' => filter_var($variant['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true, + ]; + }) + ->filter(function (array $variant): bool { + return $variant['active'] !== false + && collect([ + $variant['title'], + $variant['description'], + $variant['prompt'], + $variant['negative_prompt'], + ])->contains(fn ($item): bool => $item !== null && $item !== ''); + }) + ->values() + ->all(); + } + + /** + * @param array $notes + * @return array> + */ + private function promptPublicExamplesPayload(AcademyPromptTemplate $prompt, array $notes): array + { + $promptTitle = trim((string) $prompt->title); + + return collect($notes) + ->values() + ->filter(static fn ($note): bool => is_array($note)) + ->filter(function (array $note): bool { + return (filter_var($note['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true) !== false; + }) + ->map(function (array $note, int $index) use ($promptTitle): ?array { + $imagePayload = $this->responsiveLessonImagePayload( + (string) ($note['image_path'] ?? ''), + (string) ($note['thumb_path'] ?? ''), + ); + $imagePath = $imagePayload['image_path']; + $thumbPath = $imagePayload['thumb_path']; + $imageUrl = $imagePayload['image_url']; + $thumbUrl = $imagePayload['thumb_url']; + + if ($imageUrl === null && $thumbUrl === null) { + return null; + } + + $displayType = trim((string) ($note['display_type'] ?? '')); + $provider = trim((string) ($note['provider'] ?? '')); + $modelName = trim((string) ($note['model_name'] ?? '')); + $typeLabel = $displayType !== '' + ? (string) Str::of($displayType)->replace(['_', '-'], ' ')->headline() + : 'Prompt variation'; + $title = $displayType !== '' + ? $typeLabel + : ($modelName !== '' ? $modelName : ($provider !== '' ? $provider : sprintf('Prompt Example %02d', $index + 1))); + $caption = $displayType !== '' + ? sprintf('%s preview for %s.', $typeLabel, $promptTitle !== '' ? $promptTitle : 'this prompt') + : sprintf('Example result preview for %s.', $promptTitle !== '' ? $promptTitle : 'this prompt'); + + return [ + 'type_label' => $typeLabel, + 'title' => $title, + 'caption' => $caption, + 'alt' => sprintf('%s preview image for %s', $title, $promptTitle !== '' ? $promptTitle : 'Skinbase Academy prompt'), + 'provider' => $provider, + 'model_name' => $modelName, + 'image_path' => $imagePath, + 'image_url' => $imageUrl, + 'thumb_path' => $thumbPath, + 'thumb_url' => $thumbUrl, + 'image_srcset' => $imagePayload['srcset'], + 'score' => filled($note['score'] ?? null) ? (int) $note['score'] : null, + ]; + }) + ->filter() + ->values() + ->all(); + } + + private function promptAccessRequirement(string $accessLevel): ?string + { + return match (trim(strtolower($accessLevel))) { + 'pro' => 'Requires Pro access.', + 'creator' => 'Requires Creator or Pro access.', + default => null, + }; + } + + private function promptUnlockHeading(string $accessLevel): ?string + { + return match (trim(strtolower($accessLevel))) { + 'pro' => 'Unlock the full Pro prompt.', + 'creator' => 'Unlock the full Creator prompt.', + default => null, + }; + } + + private function promptUnlockDescription(string $accessLevel): ?string + { + return match (trim(strtolower($accessLevel))) { + 'pro' => 'Get the complete reusable prompt, negative prompt, workflow notes, model settings, and variation strategy.', + 'creator' => 'Get the complete reusable prompt, negative prompt, workflow notes, and creative workflow.', + default => null, + }; + } + /** * @param array $notes * @return array> @@ -211,17 +519,24 @@ final class AcademyAccessService return collect($notes) ->filter(static fn ($note): bool => is_array($note)) ->map(function (array $note): array { + $imagePayload = $this->responsiveLessonImagePayload( + (string) ($note['image_path'] ?? ''), + (string) ($note['thumb_path'] ?? ''), + ); + return [ + 'display_type' => trim((string) ($note['display_type'] ?? '')), 'provider' => trim((string) ($note['provider'] ?? '')), 'model_name' => trim((string) ($note['model_name'] ?? '')), 'notes' => trim((string) ($note['notes'] ?? '')), 'strengths' => trim((string) ($note['strengths'] ?? '')), 'weaknesses' => trim((string) ($note['weaknesses'] ?? '')), 'best_for' => trim((string) ($note['best_for'] ?? '')), - 'image_path' => trim((string) ($note['image_path'] ?? '')), - 'image_url' => $this->resolveLessonMediaUrl((string) ($note['image_path'] ?? '')), - 'thumb_path' => trim((string) ($note['thumb_path'] ?? '')), - 'thumb_url' => $this->resolveLessonMediaUrl((string) ($note['thumb_path'] ?? '')), + 'image_path' => $imagePayload['image_path'], + 'image_url' => $imagePayload['image_url'], + 'thumb_path' => $imagePayload['thumb_path'], + 'thumb_url' => $imagePayload['thumb_url'], + 'image_srcset' => $imagePayload['srcset'], 'settings' => trim((string) ($note['settings'] ?? '')), 'score' => filled($note['score'] ?? null) ? (int) $note['score'] : null, 'active' => filter_var($note['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true, @@ -229,6 +544,7 @@ final class AcademyAccessService }) ->filter(function (array $note): bool { return collect([ + $note['display_type'], $note['provider'], $note['model_name'], $note['notes'], @@ -296,30 +612,127 @@ final class AcademyAccessService ]; } - private function rankForUser(User $user): int - { - if (method_exists($user, 'hasAcademyProAccess') && $user->hasAcademyProAccess()) { - return $this->rankForLevel('pro'); - } - - if (method_exists($user, 'hasAcademyCreatorAccess') && $user->hasAcademyCreatorAccess()) { - return $this->rankForLevel('creator'); - } - - return $this->rankForLevel('free'); - } - private function rankForLevel(string $accessLevel): int { - return match (Str::lower(trim($accessLevel))) { + return match ($this->normalizeAccessLevel($accessLevel)) { 'admin' => 99, - 'premium' => 40, 'pro' => 30, 'creator' => 20, default => 10, }; } + private function normalizeAccessLevel(string $accessLevel): string + { + return match (Str::lower(trim($accessLevel))) { + 'admin' => 'admin', + 'pro' => 'pro', + 'creator', 'premium' => 'creator', + 'mixed' => 'free', + default => 'free', + }; + } + + private function isAcademyAdmin(User $user): bool + { + return $user->hasStaffAccess() || $user->isModerator(); + } + + private function resolveSubscriptionTier(User $user): ?string + { + $subscription = $this->activeAcademySubscription($user); + + if (! $subscription instanceof Subscription) { + return null; + } + + $matchedTier = null; + + foreach ($subscription->items as $item) { + $priceId = trim((string) $item->stripe_price); + + if ($priceId === '') { + continue; + } + + $tier = $this->priceTierMap()[$priceId] ?? null; + + if ($tier === null) { + continue; + } + + if ($matchedTier === null || $this->rankForLevel($tier) > $this->rankForLevel($matchedTier)) { + $matchedTier = $tier; + } + } + + return $matchedTier; + } + + private function resolveLegacyPaidTier(User $user): ?string + { + return match (Str::lower(trim((string) ($user->role ?? '')))) { + 'academy_pro' => 'pro', + 'academy_creator' => 'creator', + default => null, + }; + } + + private function activeAcademySubscription(User $user): ?Subscription + { + $cacheKey = (int) $user->getKey(); + + if (array_key_exists($cacheKey, $this->subscriptionCache)) { + return $this->subscriptionCache[$cacheKey]; + } + + $subscription = $user->subscription($this->subscriptionName()); + + if (! $subscription instanceof Subscription) { + return $this->subscriptionCache[$cacheKey] = null; + } + + if (! $subscription->active() && ! $subscription->onGracePeriod()) { + return $this->subscriptionCache[$cacheKey] = null; + } + + return $this->subscriptionCache[$cacheKey] = $subscription->loadMissing('items'); + } + + /** + * @return array + */ + private function priceTierMap(): array + { + if (is_array($this->priceTierMap)) { + return $this->priceTierMap; + } + + $map = []; + + foreach ((array) config('academy_billing.plans', []) as $plan) { + if (! is_array($plan)) { + continue; + } + + $priceId = trim((string) ($plan['stripe_price_id'] ?? '')); + $tier = $this->normalizeAccessLevel((string) ($plan['tier'] ?? 'free')); + + if ($priceId === '' || $tier === 'free') { + continue; + } + + $map[$priceId] = $tier; + } + + return $this->priceTierMap = $map; + } + + private function subscriptionName(): string + { + return (string) config('academy_billing.subscription_name', 'academy'); + } + private function previewText(string $value, int $limit): string { $plain = trim(strip_tags($value)); @@ -338,6 +751,33 @@ final class AcademyAccessService return rtrim(mb_substr($plain, 0, $previewLength)).'...'; } + private function nullableTrimmedString(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $normalized = trim((string) $value); + + return $normalized !== '' ? $normalized : null; + } + + /** + * @return array + */ + private function normalizeStringList(mixed $value): array + { + if (! is_array($value)) { + $value = $value === null ? [] : [$value]; + } + + return collect($value) + ->map(fn ($item): string => trim((string) $item)) + ->filter(static fn (string $item): bool => $item !== '') + ->values() + ->all(); + } + private function resolvePreviewImageUrl(string $previewImage): ?string { $previewImage = trim($previewImage); @@ -353,6 +793,25 @@ final class AcademyAccessService return Storage::disk((string) config('uploads.object_storage.disk', 's3'))->url($previewImage); } + /** + * @return array{url:?string,thumb_url:?string,srcset:?string} + */ + private function promptPreviewImagePayload(string $previewImage): array + { + $url = $this->resolvePreviewImageUrl($previewImage); + $thumbPath = $this->existingResponsiveVariantPath($previewImage, 'thumb'); + $mediumPath = $this->existingResponsiveVariantPath($previewImage, 'md'); + + return [ + 'url' => $url, + 'thumb_url' => $thumbPath !== null ? $this->resolvePreviewImageUrl($thumbPath) : $url, + 'srcset' => $this->buildResponsiveSrcset([ + ['url' => $thumbPath !== null ? $this->resolvePreviewImageUrl($thumbPath) : null, 'width' => 480], + ['url' => $mediumPath !== null ? $this->resolvePreviewImageUrl($mediumPath) : null, 'width' => 960], + ]), + ]; + } + private function resolveLessonCoverImageUrl(string $coverImage): ?string { $coverImage = trim($coverImage); @@ -383,6 +842,95 @@ final class AcademyAccessService return Storage::disk((string) config('uploads.object_storage.disk', 's3'))->url($path); } + /** + * @return array{image_path:string,image_url:?string,thumb_path:string,thumb_url:?string,srcset:?string} + */ + private function responsiveLessonImagePayload(string $imagePath, string $thumbPath = ''): array + { + $resolvedImagePath = trim($imagePath); + $resolvedThumbPath = trim($thumbPath); + $imageUrl = $this->resolveLessonMediaUrl($resolvedImagePath); + $thumbUrl = $resolvedThumbPath !== '' ? $this->resolveLessonMediaUrl($resolvedThumbPath) : $imageUrl; + $mediumPath = $resolvedThumbPath !== '' ? $this->existingResponsiveVariantPath($resolvedImagePath, 'md') : null; + + return [ + 'image_path' => $resolvedImagePath, + 'image_url' => $imageUrl, + 'thumb_path' => $resolvedThumbPath, + 'thumb_url' => $thumbUrl, + 'srcset' => $this->buildResponsiveSrcset([ + ['url' => $thumbUrl, 'width' => $resolvedThumbPath !== '' ? 480 : null], + ['url' => $mediumPath !== null ? $this->resolveLessonMediaUrl($mediumPath) : null, 'width' => $mediumPath !== null ? 960 : null], + ]), + ]; + } + + private function responsiveVariantPath(string $path, string $variant): ?string + { + $path = trim($path); + + if ($path === '' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) { + return null; + } + + $directory = pathinfo($path, PATHINFO_DIRNAME); + $filename = pathinfo($path, PATHINFO_FILENAME); + $baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename; + + return sprintf('%s/%s-%s.webp', $directory, $baseFilename, $variant); + } + + private function existingResponsiveVariantPath(string $path, string $variant): ?string + { + $variantPath = $this->responsiveVariantPath($path, $variant); + + if ($variantPath === null || ! $this->storagePathExists($variantPath)) { + return null; + } + + return $variantPath; + } + + private function storagePathExists(string $path): bool + { + $normalizedPath = trim($path); + + if ($normalizedPath === '' || str_starts_with($normalizedPath, 'http://') || str_starts_with($normalizedPath, 'https://') || str_starts_with($normalizedPath, '/')) { + return false; + } + + $cacheKey = (string) config('uploads.object_storage.disk', 's3') . ':' . $normalizedPath; + + if (array_key_exists($cacheKey, $this->assetExistsCache)) { + return $this->assetExistsCache[$cacheKey]; + } + + try { + $exists = Storage::disk((string) config('uploads.object_storage.disk', 's3'))->exists($normalizedPath); + } catch (\Throwable) { + $exists = false; + } + + $this->assetExistsCache[$cacheKey] = $exists; + + return $exists; + } + + /** + * @param array $variants + */ + private function buildResponsiveSrcset(array $variants): ?string + { + $entries = collect($variants) + ->filter(static fn (array $variant): bool => filled($variant['url'] ?? null) && (int) ($variant['width'] ?? 0) > 0) + ->unique(fn (array $variant): string => (string) $variant['url']) + ->map(fn (array $variant): string => sprintf('%s %dw', (string) $variant['url'], (int) $variant['width'])) + ->values() + ->all(); + + return $entries !== [] ? implode(', ', $entries) : null; + } + /** * @return array|null */ @@ -399,22 +947,30 @@ final class AcademyAccessService ->values() ->all(); $results = $block->activeComparisonResults - ->map(fn (AcademyAiComparisonResult $result): array => [ - 'id' => (int) $result->id, - 'provider' => (string) ($result->provider ?? ''), - 'model_name' => (string) ($result->model_name ?? ''), - 'image_path' => (string) $result->image_path, - 'image_url' => $this->resolveLessonMediaUrl((string) $result->image_path), - 'thumb_path' => (string) ($result->thumb_path ?? ''), - 'thumb_url' => $this->resolveLessonMediaUrl((string) ($result->thumb_path ?? '')), - 'settings' => (string) ($result->settings ?? ''), - 'strengths' => (string) ($result->strengths ?? ''), - 'weaknesses' => (string) ($result->weaknesses ?? ''), - 'best_for' => (string) ($result->best_for ?? ''), - 'score' => $result->score, - 'sort_order' => (int) $result->sort_order, - 'active' => (bool) $result->active, - ]) + ->map(function (AcademyAiComparisonResult $result): array { + $imagePayload = $this->responsiveLessonImagePayload( + (string) $result->image_path, + (string) ($result->thumb_path ?? ''), + ); + + return [ + 'id' => (int) $result->id, + 'provider' => (string) ($result->provider ?? ''), + 'model_name' => (string) ($result->model_name ?? ''), + 'image_path' => $imagePayload['image_path'], + 'image_url' => $imagePayload['image_url'], + 'thumb_path' => $imagePayload['thumb_path'], + 'thumb_url' => $imagePayload['thumb_url'], + 'image_srcset' => $imagePayload['srcset'], + 'settings' => (string) ($result->settings ?? ''), + 'strengths' => (string) ($result->strengths ?? ''), + 'weaknesses' => (string) ($result->weaknesses ?? ''), + 'best_for' => (string) ($result->best_for ?? ''), + 'score' => $result->score, + 'sort_order' => (int) $result->sort_order, + 'active' => (bool) $result->active, + ]; + }) ->values() ->all(); diff --git a/app/Services/Academy/AcademyAdminBillingOverviewService.php b/app/Services/Academy/AcademyAdminBillingOverviewService.php new file mode 100644 index 00000000..acf6f042 --- /dev/null +++ b/app/Services/Academy/AcademyAdminBillingOverviewService.php @@ -0,0 +1,194 @@ + + */ + public function summary(): array + { + $subscriptions = Subscription::query() + ->where('type', $this->plans->subscriptionName()) + ->with('items') + ->get(); + + $activeSubscriptions = $subscriptions->filter( + fn (Subscription $subscription): bool => $subscription->active() || $subscription->onGracePeriod() + ); + + $subscriberTiers = []; + $planBreakdown = []; + $gracePeriodSubscribers = []; + + foreach ($activeSubscriptions as $subscription) { + $userId = (int) $subscription->user_id; + $tier = $this->tierForSubscription($subscription); + + if ($subscription->onGracePeriod()) { + $gracePeriodSubscribers[$userId] = true; + } + + if ($tier !== null) { + $existingTier = $subscriberTiers[$userId] ?? null; + + if ($existingTier === null || $this->rankForTier($tier) > $this->rankForTier($existingTier)) { + $subscriberTiers[$userId] = $tier; + } + } + + foreach ($this->planKeysForSubscription($subscription) as $planKey) { + $planBreakdown[$planKey] = (int) ($planBreakdown[$planKey] ?? 0) + 1; + } + } + + $recentEvents = AcademyBillingEvent::query()->count(); + $lastWebhookAt = AcademyBillingEvent::query()->latest('processed_at')->value('processed_at'); + + return [ + 'enabled' => $this->plans->enabled(), + 'active_subscribers' => count($subscriberTiers), + 'creator_subscribers' => count(array_filter($subscriberTiers, static fn (string $tier): bool => $tier === 'creator')), + 'pro_subscribers' => count(array_filter($subscriberTiers, static fn (string $tier): bool => $tier === 'pro')), + 'grace_period_subscribers' => count($gracePeriodSubscribers), + 'ended_subscriptions' => $subscriptions->filter( + fn (Subscription $subscription): bool => ! $subscription->active() && ! $subscription->onGracePeriod() + )->count(), + 'configured_plan_count' => count(array_keys($this->plans->plans())), + 'missing_plan_keys' => $this->plans->missingPriceIds(), + 'plan_breakdown' => $this->formatPlanBreakdown($planBreakdown), + 'recent_webhook_count' => $recentEvents, + 'last_webhook_at' => $lastWebhookAt?->toISOString(), + ]; + } + + /** + * @return array> + */ + public function recentEvents(int $limit = 15): array + { + return AcademyBillingEvent::query() + ->latest('processed_at') + ->latest('id') + ->limit($limit) + ->get() + ->map(fn (AcademyBillingEvent $event): array => [ + 'id' => (int) $event->id, + 'event_type' => (string) $event->event_type, + 'academy_tier' => $event->academy_tier ? (string) $event->academy_tier : null, + 'academy_plan' => $event->academy_plan ? (string) $event->academy_plan : null, + 'user_id' => $event->user_id ? (int) $event->user_id : null, + 'stripe_customer_id' => $event->stripe_customer_id ? (string) $event->stripe_customer_id : null, + 'stripe_subscription_id' => $event->stripe_subscription_id ? (string) $event->stripe_subscription_id : null, + 'processed_at' => $event->processed_at?->toISOString(), + 'created_at' => $event->created_at?->toISOString(), + 'payload_summary' => is_array($event->payload_summary) ? $event->payload_summary : [], + ]) + ->values() + ->all(); + } + + /** + * @return array> + */ + private function formatPlanBreakdown(array $planBreakdown): array + { + return collect(array_keys($this->plans->plans())) + ->map(function (string $planKey) use ($planBreakdown): array { + $plan = $this->plans->plan($planKey); + + return [ + 'key' => $planKey, + 'label' => (string) ($plan['label'] ?? $planKey), + 'tier' => (string) ($plan['tier'] ?? 'free'), + 'interval' => (string) ($plan['interval'] ?? 'monthly'), + 'configured' => (bool) ($plan['configured'] ?? false), + 'subscribers' => (int) ($planBreakdown[$planKey] ?? 0), + ]; + }) + ->values() + ->all(); + } + + /** + * @return list + */ + private function planKeysForSubscription(Subscription $subscription): array + { + $keys = []; + + foreach ($this->priceIdsForSubscription($subscription) as $priceId) { + $plan = $this->plans->planForPriceId($priceId); + + if ($plan === null) { + continue; + } + + $keys[] = (string) ($plan['key'] ?? ''); + } + + return array_values(array_unique(array_filter($keys, static fn (string $key): bool => $key !== ''))); + } + + private function tierForSubscription(Subscription $subscription): ?string + { + $matchedTier = null; + + foreach ($this->priceIdsForSubscription($subscription) as $priceId) { + $plan = $this->plans->planForPriceId($priceId); + + if ($plan === null) { + continue; + } + + $tier = (string) ($plan['tier'] ?? 'free'); + + if ($matchedTier === null || $this->rankForTier($tier) > $this->rankForTier($matchedTier)) { + $matchedTier = $tier; + } + } + + return $matchedTier; + } + + /** + * @return Collection + */ + private function priceIdsForSubscription(Subscription $subscription): Collection + { + $priceIds = $subscription->items + ->pluck('stripe_price') + ->filter(fn ($value): bool => is_string($value) && trim($value) !== '') + ->map(fn (string $value): string => trim($value)); + + if ($priceIds->isNotEmpty()) { + return $priceIds->values(); + } + + $fallbackPrice = trim((string) $subscription->stripe_price); + + return $fallbackPrice === '' + ? collect() + : collect([$fallbackPrice]); + } + + private function rankForTier(string $tier): int + { + return match ($this->plans->normalizeTier($tier)) { + 'pro' => 2, + 'creator' => 1, + default => 0, + }; + } +} \ No newline at end of file diff --git a/app/Services/Academy/AcademyAnalyticsContentResolver.php b/app/Services/Academy/AcademyAnalyticsContentResolver.php new file mode 100644 index 00000000..b9420460 --- /dev/null +++ b/app/Services/Academy/AcademyAnalyticsContentResolver.php @@ -0,0 +1,70 @@ + AcademyPromptTemplate::class, + AcademyAnalyticsContentType::LESSON => AcademyLesson::class, + AcademyAnalyticsContentType::COURSE => AcademyCourse::class, + AcademyAnalyticsContentType::PROMPT_PACK => AcademyPromptPack::class, + AcademyAnalyticsContentType::CHALLENGE => AcademyChallenge::class, + default => null, + }; + + if ($modelClass === null) { + return null; + } + + return $modelClass::query()->find($contentId); + } + + public function exists(string $contentType, int $contentId): bool + { + return $this->resolve($contentType, $contentId) instanceof Model; + } + + public function title(string $contentType, ?int $contentId): string + { + if (! $contentId) { + return match ($contentType) { + AcademyAnalyticsContentType::HOME => 'Academy Home', + AcademyAnalyticsContentType::SEARCH => 'Academy Search', + AcademyAnalyticsContentType::UPGRADE => 'Academy Upgrade', + default => 'Unknown Academy Content', + }; + } + + $content = $this->resolve($contentType, $contentId); + + if (! $content instanceof Model) { + return 'Unknown Academy Content'; + } + + return (string) ($content->title ?? $content->name ?? sprintf('%s #%d', $contentType, $contentId)); + } + + public function accessLevel(string $contentType, ?int $contentId): ?string + { + if (! $contentId) { + return null; + } + + $content = $this->resolve($contentType, $contentId); + + return $content instanceof Model ? (string) ($content->access_level ?? '') : null; + } +} \ No newline at end of file diff --git a/app/Services/Academy/AcademyAnalyticsService.php b/app/Services/Academy/AcademyAnalyticsService.php new file mode 100644 index 00000000..d6f50211 --- /dev/null +++ b/app/Services/Academy/AcademyAnalyticsService.php @@ -0,0 +1,369 @@ + + */ + private const KNOWN_BOT_PATTERNS = [ + 'googlebot', + 'bingbot', + 'ahrefsbot', + 'semrushbot', + 'dotbot', + 'barkrowler', + 'claudebot', + 'gptbot', + 'amazonbot', + 'mj12bot', + 'petalbot', + 'yandexbot', + 'bytespider', + 'crawler', + 'spider', + 'headless', + 'preview', + ]; + + public function __construct(private readonly AcademyAnalyticsContentResolver $contentResolver) + { + } + + /** + * @param array $payload + */ + public function track(array $payload, ?User $user = null, ?Request $request = null): AcademyEvent + { + $request ??= request(); + $user ??= $request?->user(); + + $eventType = trim((string) ($payload['event_type'] ?? '')); + $contentType = $this->normalizeNullableString($payload['content_type'] ?? null); + $contentId = filled($payload['content_id'] ?? null) ? (int) $payload['content_id'] : null; + $metadata = is_array($payload['metadata'] ?? null) ? $payload['metadata'] : []; + $rawOccurredAt = $payload['occurred_at'] ?? null; + $occurredAt = $rawOccurredAt instanceof Carbon + ? $rawOccurredAt + : Carbon::parse((string) ($rawOccurredAt ?? now()->toISOString())); + $userAgent = strtolower(trim((string) ($request?->userAgent() ?? ''))); + $isBot = $this->looksLikeBot($userAgent); + $isCrawler = $isBot || str_contains($userAgent, 'crawl'); + $isAdmin = $user ? ($user->hasStaffAccess() || $user->isModerator()) : false; + $isSubscriber = $user ? ($user->hasAcademyProAccess() || $user->hasAcademyCreatorAccess()) : false; + $visitorId = $this->resolveVisitorId($payload, $request, $user); + + $event = AcademyEvent::query()->create([ + 'event_type' => $eventType, + 'content_type' => $contentType, + 'content_id' => $contentId, + 'user_id' => $user?->id, + 'visitor_id' => $visitorId, + 'session_id' => $this->normalizeNullableString($payload['session_id'] ?? ($request?->hasSession() ? $request->session()->getId() : null)), + 'url' => $this->normalizeNullableString($payload['url'] ?? $request?->fullUrl()), + 'route_name' => $this->normalizeNullableString($payload['route_name'] ?? $request?->route()?->getName()), + 'referrer' => $this->normalizeNullableString($payload['referrer'] ?? $request?->headers->get('referer')), + 'utm_source' => $this->normalizeNullableString($payload['utm_source'] ?? $request?->query('utm_source')), + 'utm_medium' => $this->normalizeNullableString($payload['utm_medium'] ?? $request?->query('utm_medium')), + 'utm_campaign' => $this->normalizeNullableString($payload['utm_campaign'] ?? $request?->query('utm_campaign')), + 'device_type' => $this->deviceTypeFromUserAgent($userAgent), + 'browser' => $this->browserFromUserAgent($userAgent), + 'platform' => $this->platformFromUserAgent($userAgent), + 'country_code' => $this->countryCodeFromRequest($request), + 'is_logged_in' => $user !== null, + 'is_subscriber' => $isSubscriber, + 'is_admin' => $isAdmin, + 'is_bot' => $isBot, + 'is_crawler' => $isCrawler, + 'is_suspicious' => $isBot || $this->looksSuspicious($request, $userAgent), + 'metadata' => $metadata === [] ? null : $metadata, + 'occurred_at' => $occurredAt, + ]); + + if ($eventType === AcademyAnalyticsEventType::SEARCH_RESULT_CLICK) { + $this->syncSearchResultClickAttribution($event, $metadata, $request, $user); + } + + return $event; + } + + public function trackContentView(string $contentType, ?int $contentId, Request $request): void + { + $this->track([ + 'event_type' => AcademyAnalyticsEventType::PAGE_VIEW, + 'content_type' => $contentType, + 'content_id' => $contentId, + 'metadata' => ['source' => 'academy_page'], + ], $request->user(), $request); + + $specificEvent = match ($contentType) { + AcademyAnalyticsContentType::PROMPT => AcademyAnalyticsEventType::CONTENT_VIEW, + AcademyAnalyticsContentType::LESSON => AcademyAnalyticsEventType::LESSON_VIEW, + AcademyAnalyticsContentType::COURSE => AcademyAnalyticsEventType::COURSE_VIEW, + AcademyAnalyticsContentType::PROMPT_PACK => AcademyAnalyticsEventType::PROMPT_PACK_VIEW, + AcademyAnalyticsContentType::CHALLENGE => AcademyAnalyticsEventType::CHALLENGE_VIEW, + default => AcademyAnalyticsEventType::CONTENT_VIEW, + }; + + $this->track([ + 'event_type' => $specificEvent, + 'content_type' => $contentType, + 'content_id' => $contentId, + ], $request->user(), $request); + } + + public function trackPromptCopy(int $promptId, string $copyType, Request $request): void + { + $eventType = trim(strtolower($copyType)) === 'negative' + ? AcademyAnalyticsEventType::PROMPT_NEGATIVE_COPY + : AcademyAnalyticsEventType::PROMPT_COPY; + + $this->track([ + 'event_type' => $eventType, + 'content_type' => AcademyAnalyticsContentType::PROMPT, + 'content_id' => $promptId, + 'metadata' => [ + 'copy_type' => $copyType, + 'source' => 'prompt_detail', + ], + ], $request->user(), $request); + } + + public function trackUpgradeClick(?string $source, ?string $contentType, ?int $contentId, Request $request): void + { + $this->track([ + 'event_type' => AcademyAnalyticsEventType::UPGRADE_CLICK, + 'content_type' => $contentType ?: AcademyAnalyticsContentType::UPGRADE, + 'content_id' => $contentId, + 'metadata' => array_filter([ + 'source' => $this->normalizeNullableString($source), + ]), + ], $request->user(), $request); + } + + /** + * @param array $filters + */ + public function trackSearch(string $query, int $resultsCount, array $filters = [], ?Request $request = null): AcademySearchLog + { + $request ??= request(); + $user = $request?->user(); + $normalizedQuery = $this->normalizeSearchQuery($query); + $isBot = $this->looksLikeBot(strtolower(trim((string) ($request?->userAgent() ?? '')))); + + $log = AcademySearchLog::query()->create([ + 'user_id' => $user?->id, + 'visitor_id' => $this->resolveVisitorId([], $request, $user), + 'query' => trim($query), + 'normalized_query' => $normalizedQuery, + 'results_count' => max(0, $resultsCount), + 'filters' => $filters === [] ? null : $filters, + 'is_logged_in' => $user !== null, + 'is_subscriber' => $user ? ($user->hasAcademyCreatorAccess() || $user->hasAcademyProAccess()) : false, + 'is_bot' => $isBot, + ]); + + $this->track([ + 'event_type' => AcademyAnalyticsEventType::SEARCH, + 'content_type' => AcademyAnalyticsContentType::SEARCH, + 'metadata' => [ + 'query' => $normalizedQuery, + 'results_count' => $resultsCount, + 'filters' => $filters, + ], + ], $user, $request); + + if ($resultsCount === 0) { + $this->track([ + 'event_type' => AcademyAnalyticsEventType::ZERO_SEARCH_RESULTS, + 'content_type' => AcademyAnalyticsContentType::SEARCH, + 'metadata' => [ + 'query' => $normalizedQuery, + 'filters' => $filters, + ], + ], $user, $request); + } + + return $log; + } + + public function normalizeSearchQuery(string $query): string + { + $value = strtolower(trim($query)); + $value = preg_replace('/\s+/', ' ', $value) ?? $value; + $value = preg_replace('/[^a-z0-9\s\-_]+/', '', $value) ?? $value; + + return trim($value); + } + + /** + * @param array $metadata + */ + private function syncSearchResultClickAttribution(AcademyEvent $event, array $metadata, ?Request $request, ?User $user): AcademySearchLog + { + $query = trim((string) ($metadata['query'] ?? '')); + $normalizedQuery = $this->normalizeSearchQuery((string) ($metadata['normalized_query'] ?? $query)); + $resultsCount = max(0, (int) ($metadata['results_count'] ?? 0)); + $filters = is_array($metadata['filters'] ?? null) ? $metadata['filters'] : []; + $visitorId = $this->normalizeNullableString($event->visitor_id) ?? $this->resolveVisitorId([], $request, $user); + $recentThreshold = ($event->occurred_at ?? now())->copy()->subMinutes(30); + + $searchLog = AcademySearchLog::query() + ->where('normalized_query', $normalizedQuery) + ->where('created_at', '>=', $recentThreshold) + ->whereNull('clicked_content_id') + ->where(function ($builder) use ($user, $visitorId): void { + if ($user?->id !== null) { + $builder->orWhere('user_id', $user->id); + } + + if ($visitorId !== null) { + $builder->orWhere('visitor_id', $visitorId); + } + }) + ->latest('id') + ->first(); + + if ($searchLog instanceof AcademySearchLog) { + $searchLog->forceFill([ + 'clicked_content_type' => $event->content_type, + 'clicked_content_id' => $event->content_id, + ])->save(); + + return $searchLog; + } + + return AcademySearchLog::query()->create([ + 'user_id' => $user?->id, + 'visitor_id' => $visitorId, + 'query' => $query, + 'normalized_query' => $normalizedQuery, + 'results_count' => $resultsCount, + 'clicked_content_type' => $event->content_type, + 'clicked_content_id' => $event->content_id, + 'filters' => $filters === [] ? null : $filters, + 'is_logged_in' => $user !== null, + 'is_subscriber' => (bool) $event->is_subscriber, + 'is_bot' => (bool) $event->is_bot, + ]); + } + + private function resolveVisitorId(array $payload, ?Request $request, ?User $user): ?string + { + $payloadVisitorId = $this->normalizeNullableString($payload['visitor_id'] ?? null); + if ($payloadVisitorId !== null) { + return $payloadVisitorId; + } + + $cookieVisitorId = $this->normalizeNullableString($request?->cookie('academy_visitor_id')); + if ($cookieVisitorId !== null) { + return $cookieVisitorId; + } + + if ($user) { + return sprintf('user:%d', $user->id); + } + + return (string) Str::uuid(); + } + + private function looksLikeBot(string $userAgent): bool + { + if ($userAgent === '') { + return false; + } + + foreach (self::KNOWN_BOT_PATTERNS as $pattern) { + if (str_contains($userAgent, $pattern)) { + return true; + } + } + + return false; + } + + private function looksSuspicious(?Request $request, string $userAgent): bool + { + if ($request === null) { + return false; + } + + return $this->looksLikeBot($userAgent) + || str_contains(strtolower((string) $request->headers->get('accept', '')), '*/*') + || $request->headers->get('sec-fetch-site') === null; + } + + private function deviceTypeFromUserAgent(string $userAgent): string + { + if ($userAgent === '') { + return 'unknown'; + } + + if (str_contains($userAgent, 'tablet') || str_contains($userAgent, 'ipad')) { + return 'tablet'; + } + + if (str_contains($userAgent, 'mobile') || str_contains($userAgent, 'android')) { + return 'mobile'; + } + + return 'desktop'; + } + + private function browserFromUserAgent(string $userAgent): ?string + { + if ($userAgent === '') { + return null; + } + + return match (true) { + str_contains($userAgent, 'edg/') => 'Edge', + str_contains($userAgent, 'chrome/') => 'Chrome', + str_contains($userAgent, 'firefox/') => 'Firefox', + str_contains($userAgent, 'safari/') && ! str_contains($userAgent, 'chrome/') => 'Safari', + default => 'Other', + }; + } + + private function platformFromUserAgent(string $userAgent): ?string + { + if ($userAgent === '') { + return null; + } + + return match (true) { + str_contains($userAgent, 'windows') => 'Windows', + str_contains($userAgent, 'mac os') || str_contains($userAgent, 'macintosh') => 'macOS', + str_contains($userAgent, 'android') => 'Android', + str_contains($userAgent, 'iphone') || str_contains($userAgent, 'ipad') || str_contains($userAgent, 'ios') => 'iOS', + str_contains($userAgent, 'linux') => 'Linux', + default => 'Other', + }; + } + + private function countryCodeFromRequest(?Request $request): ?string + { + $country = strtoupper(trim((string) ($request?->headers->get('cf-ipcountry') ?? $request?->headers->get('x-country-code') ?? ''))); + + return $country !== '' && strlen($country) <= 8 ? $country : null; + } + + private function normalizeNullableString(mixed $value): ?string + { + $normalized = trim((string) $value); + + return $normalized === '' ? null : $normalized; + } +} \ No newline at end of file diff --git a/app/Services/Academy/AcademyBillingPlanService.php b/app/Services/Academy/AcademyBillingPlanService.php new file mode 100644 index 00000000..550bd6a8 --- /dev/null +++ b/app/Services/Academy/AcademyBillingPlanService.php @@ -0,0 +1,148 @@ +> + */ + public function plans(): array + { + $plans = config('academy_billing.plans', []); + + return is_array($plans) ? $plans : []; + } + + public function normalizePlanKey(?string $planKey): string + { + return Str::of((string) $planKey) + ->trim() + ->lower() + ->replace('-', '_') + ->value(); + } + + /** + * @return array|null + */ + public function plan(?string $planKey): ?array + { + $normalized = $this->normalizePlanKey($planKey); + + if ($normalized === '') { + return null; + } + + $plan = Arr::get($this->plans(), $normalized); + + if (! is_array($plan)) { + return null; + } + + $plan['key'] = $normalized; + $plan['tier'] = $this->normalizeTier((string) ($plan['tier'] ?? 'free')); + $plan['interval'] = Str::lower(trim((string) ($plan['interval'] ?? 'monthly'))); + $plan['amount'] = trim((string) ($plan['amount'] ?? '')); + $plan['currency'] = Str::upper(trim((string) ($plan['currency'] ?? config('cashier.currency', 'EUR')))); + $plan['stripe_price_id'] = trim((string) ($plan['stripe_price_id'] ?? '')); + $plan['configured'] = $plan['stripe_price_id'] !== ''; + $plan['price_id_valid'] = $this->isValidPriceId($plan['stripe_price_id']); + $plan['price_display'] = $plan['amount'] !== '' ? $plan['amount'].' '.$plan['currency'] : null; + + return $plan; + } + + /** + * @return array|null + */ + public function planForPriceId(?string $priceId): ?array + { + $priceId = trim((string) $priceId); + + if ($priceId === '') { + return null; + } + + foreach (array_keys($this->plans()) as $planKey) { + $plan = $this->plan((string) $planKey); + + if ($plan !== null && ($plan['stripe_price_id'] ?? null) === $priceId) { + return $plan; + } + } + + return null; + } + + /** + * @return array + */ + public function missingPriceIds(?string $planKey = null): array + { + if ($planKey !== null) { + $plan = $this->plan($planKey); + + return $plan !== null && ! ($plan['configured'] ?? false) + ? [$this->normalizePlanKey($planKey)] + : []; + } + + return collect(array_keys($this->plans())) + ->filter(fn (string $key): bool => ! ((bool) ($this->plan($key)['configured'] ?? false))) + ->values() + ->all(); + } + + public function assertConfigured(?string $planKey = null): void + { + if (app()->environment(['local', 'testing'])) { + return; + } + + $missingPlans = $this->missingPriceIds($planKey); + + if ($missingPlans === []) { + return; + } + + throw new RuntimeException('Academy billing price IDs are missing for: '.implode(', ', $missingPlans)); + } + + public function normalizeTier(string $tier): string + { + return match (Str::lower(trim($tier))) { + 'admin' => 'admin', + 'pro' => 'pro', + 'creator', 'premium' => 'creator', + default => 'free', + }; + } + + public function isValidPriceId(?string $priceId): bool + { + $priceId = trim((string) $priceId); + + if ($priceId === '') { + return false; + } + + return preg_match('/^price_[A-Za-z0-9]+$/', $priceId) === 1; + } +} \ No newline at end of file diff --git a/app/Services/Academy/AcademyContentIntelligenceService.php b/app/Services/Academy/AcademyContentIntelligenceService.php new file mode 100644 index 00000000..a0078105 --- /dev/null +++ b/app/Services/Academy/AcademyContentIntelligenceService.php @@ -0,0 +1,784 @@ + $filters + * @return array + */ + public function getContentOpportunities(array $filters = []): array + { + return $this->remember('content-opportunities', $filters, function (Carbon $from, Carbon $to, int $limit): array { + $searchGaps = $this->getSearchGaps(['from' => $from, 'to' => $to, 'limit' => $limit]); + $promptInsights = $this->getPromptInsights(['from' => $from, 'to' => $to, 'limit' => $limit]); + $lessonDropoffs = $this->getLessonDropoffs(['from' => $from, 'to' => $to, 'limit' => $limit]); + $courseHealth = $this->getCourseHealth(['from' => $from, 'to' => $to, 'limit' => $limit]); + $premiumInterest = $this->getPremiumInterest(['from' => $from, 'to' => $to, 'limit' => $limit]); + $recommendations = $this->getEditorialRecommendations(['from' => $from, 'to' => $to, 'limit' => $limit]); + + $cards = [ + [ + 'label' => 'Content opportunities', + 'value' => count($recommendations['rows']), + 'description' => 'Actionable content, conversion, and editorial recommendations generated from Academy analytics.', + ], + [ + 'label' => 'Search gaps', + 'value' => (int) $searchGaps['summary']['gap_count'], + 'description' => 'Queries with zero results, weak CTR, or no result clicks.', + ], + [ + 'label' => 'Prompt insights', + 'value' => (int) $promptInsights['summary']['signal_count'], + 'description' => 'Prompts that should be improved, promoted, or expanded.', + ], + [ + 'label' => 'Lesson drop-offs', + 'value' => (int) $lessonDropoffs['summary']['signal_count'], + 'description' => 'Lessons losing users before they meaningfully start or finish.', + ], + [ + 'label' => 'Course health', + 'value' => (int) $courseHealth['summary']['signal_count'], + 'description' => 'Courses that need restructuring or are ready for expansion.', + ], + [ + 'label' => 'Premium interest', + 'value' => (int) $premiumInterest['summary']['signal_count'], + 'description' => 'Content that shows premium teaser strength or weakness.', + ], + [ + 'label' => 'Editorial recommendations', + 'value' => (int) $recommendations['summary']['total'], + 'description' => 'Prioritized actions for what to create, improve, promote, or premiumize next.', + ], + ]; + + return [ + 'cards' => $cards, + 'highlights' => collect($recommendations['rows']) + ->take(6) + ->map(fn (array $row): array => [ + 'title' => (string) $row['title'], + 'priority' => (string) $row['priority'], + 'reason' => (string) $row['reason'], + 'suggested_action' => (string) $row['suggested_action'], + ]) + ->values() + ->all(), + ]; + }); + } + + /** + * @param array $filters + * @return array + */ + public function getSearchGaps(array $filters = []): array + { + return $this->remember('search-gaps', $filters, function (Carbon $from, Carbon $to, int $limit): array { + $rows = $this->searchQuery($from, $to)->get()->map(function ($row): array { + $searches = max(0, (int) $row->searches); + $clicks = max(0, (int) ($row->clicks ?? 0)); + $resultsCount = round((float) ($row->avg_results_count ?? 0), 1); + $ctr = $searches > 0 ? round(($clicks / $searches) * 100, 1) : 0.0; + $signal = $this->classifySearchGap( + searches: $searches, + resultsCount: $resultsCount, + clicks: $clicks, + ctr: $ctr, + loggedInSearches: max(0, (int) ($row->logged_in_searches ?? 0)), + subscriberSearches: max(0, (int) ($row->subscriber_searches ?? 0)), + ); + + return [ + 'query' => (string) ($row->query ?: $row->normalized_query), + 'normalized_query' => (string) $row->normalized_query, + 'searches' => $searches, + 'results_count' => $resultsCount, + 'clicks' => $clicks, + 'ctr' => $ctr, + 'last_searched_at' => $row->last_searched_at ? Carbon::parse((string) $row->last_searched_at)->toDateTimeString() : null, + 'logged_in_searches' => max(0, (int) ($row->logged_in_searches ?? 0)), + 'subscriber_searches' => max(0, (int) ($row->subscriber_searches ?? 0)), + 'issue' => $signal['issue'], + 'priority' => $signal['priority'], + 'priority_score' => $signal['priority_score'], + 'suggested_action' => $signal['suggested_action'], + ]; + })->filter(fn (array $row): bool => $row['issue'] !== null)->values(); + + $zeroResultSearches = $rows + ->filter(fn (array $row): bool => $row['issue'] === 'Zero-result demand') + ->sortByDesc('searches') + ->take($limit) + ->values(); + $searchesWithResultsNoClicks = $rows + ->filter(fn (array $row): bool => $row['issue'] === 'Results with no clicks') + ->sortByDesc('searches') + ->take($limit) + ->values(); + $lowCtrSearches = $rows + ->filter(fn (array $row): bool => $row['issue'] === 'Low click-through rate') + ->sortBy('ctr') + ->take($limit) + ->values(); + $highCtrSearches = $rows + ->filter(fn (array $row): bool => $row['issue'] === 'High click-through topic') + ->sortByDesc('ctr') + ->take($limit) + ->values(); + $repeatedQueries = $rows + ->filter(fn (array $row): bool => $row['logged_in_searches'] >= 2 || $row['subscriber_searches'] >= 2) + ->sortByDesc('searches') + ->take($limit) + ->values(); + + $dedupedRows = $this->dedupeByKey( + collect([$zeroResultSearches, $searchesWithResultsNoClicks, $lowCtrSearches, $highCtrSearches]) + ->flatten(1) + ->sortByDesc('priority_score') + ->sortByDesc('searches') + ->values(), + 'normalized_query', + )->take($limit)->values(); + + return [ + 'summary' => [ + 'gap_count' => $dedupedRows->count(), + 'zero_result_count' => $zeroResultSearches->count(), + 'no_click_count' => $searchesWithResultsNoClicks->count(), + 'low_ctr_count' => $lowCtrSearches->count(), + 'high_ctr_count' => $highCtrSearches->count(), + 'repeated_member_count' => $repeatedQueries->count(), + ], + 'rows' => $dedupedRows->all(), + 'zero_result_searches' => $zeroResultSearches->all(), + 'searches_with_results_no_clicks' => $searchesWithResultsNoClicks->all(), + 'low_ctr_searches' => $lowCtrSearches->all(), + 'high_ctr_searches' => $highCtrSearches->all(), + 'repeated_queries' => $repeatedQueries->all(), + ]; + }); + } + + /** + * @param array $filters + * @return array + */ + public function getPromptInsights(array $filters = []): array + { + return $this->remember('prompt-insights', $filters, function (Carbon $from, Carbon $to, int $limit): array { + $rows = $this->contentMetrics($from, $to, AcademyAnalyticsContentType::PROMPT)->map(function (array $row): ?array { + $signal = $this->classifyPromptInsight($row); + + if ($signal === null) { + return null; + } + + return array_merge($row, $signal); + })->filter()->sortByDesc('priority_score')->take($limit)->values(); + + return [ + 'summary' => [ + 'signal_count' => $rows->count(), + 'high_view_low_copy' => $rows->where('issue', 'High views, low copies')->count(), + 'low_view_high_copy_rate' => $rows->where('issue', 'Low views, high copy rate')->count(), + 'high_save_low_copy' => $rows->where('issue', 'High saves, low copies')->count(), + 'high_copy_low_like' => $rows->where('issue', 'High copies, low likes')->count(), + 'high_upgrade_interest' => $rows->where('issue', 'High upgrade interest')->count(), + ], + 'rows' => $rows->all(), + ]; + }); + } + + /** + * @param array $filters + * @return array + */ + public function getLessonDropoffs(array $filters = []): array + { + return $this->remember('lesson-dropoffs', $filters, function (Carbon $from, Carbon $to, int $limit): array { + $rows = $this->contentMetrics($from, $to, AcademyAnalyticsContentType::LESSON)->map(function (array $row): ?array { + $signal = $this->classifyLessonDropoff($row); + + if ($signal === null) { + return null; + } + + return array_merge($row, $signal); + })->filter()->sortByDesc('priority_score')->take($limit)->values(); + + return [ + 'summary' => [ + 'signal_count' => $rows->count(), + 'low_start_rate' => $rows->where('issue', 'High views, low starts')->count(), + 'low_completion_rate' => $rows->where('issue', 'High starts, low completions')->count(), + 'underpromoted_winners' => $rows->where('issue', 'High completions, low views')->count(), + 'upgrade_interest' => $rows->where('issue', 'Upgrade interest')->count(), + ], + 'rows' => $rows->all(), + ]; + }); + } + + /** + * @param array $filters + * @return array + */ + public function getCourseHealth(array $filters = []): array + { + return $this->remember('course-health', $filters, function (Carbon $from, Carbon $to, int $limit): array { + $progress = AcademyUserProgress::query() + ->selectRaw('course_id, avg(progress_percent) as avg_progress_percent, count(*) as learners') + ->whereNotNull('course_id') + ->whereNull('lesson_id') + ->whereBetween('updated_at', [$from, $to]) + ->groupBy('course_id') + ->get() + ->keyBy(fn ($row): int => (int) $row->course_id); + + $rows = $this->contentMetrics($from, $to, AcademyAnalyticsContentType::COURSE)->map(function (array $row) use ($progress): ?array { + $courseProgress = $progress->get((int) $row['content_id']); + $row['avg_progress'] = $courseProgress ? round((float) ($courseProgress->avg_progress_percent ?? 0), 1) : 0.0; + $row['learners'] = $courseProgress ? (int) ($courseProgress->learners ?? 0) : 0; + $signal = $this->classifyCourseHealth($row); + + if ($signal === null) { + return null; + } + + return array_merge($row, $signal); + })->filter()->sortByDesc('priority_score')->take($limit)->values(); + + return [ + 'summary' => [ + 'signal_count' => $rows->count(), + 'low_start_rate' => $rows->where('issue', 'Low course start rate')->count(), + 'low_completion_rate' => $rows->where('issue', 'Low course completion rate')->count(), + 'expandable_courses' => $rows->where('issue', 'Expansion candidate')->count(), + 'upgrade_interest' => $rows->where('issue', 'Premium follow-up opportunity')->count(), + ], + 'rows' => $rows->all(), + ]; + }); + } + + /** + * @param array $filters + * @return array + */ + public function getPremiumInterest(array $filters = []): array + { + return $this->remember('premium-interest', $filters, function (Carbon $from, Carbon $to, int $limit): array { + $rows = collect([ + ...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::PROMPT)->all(), + ...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::LESSON)->all(), + ...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::COURSE)->all(), + ...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::PROMPT_PACK)->all(), + ...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::CHALLENGE)->all(), + ])->map(function (array $row): ?array { + $signal = $this->classifyPremiumInterest($row); + + if ($signal === null) { + return null; + } + + return array_merge($row, $signal, [ + 'premium_interest_score' => round(((float) $row['premium_preview_views'] * 2) + ((float) $row['upgrade_clicks'] * 10), 1), + ]); + })->filter()->sortByDesc('priority_score')->sortByDesc('premium_interest_score')->take($limit)->values(); + + return [ + 'summary' => [ + 'signal_count' => $rows->count(), + 'strong_candidates' => $rows->where('issue', 'Strong premium candidate')->count(), + 'weak_teasers' => $rows->where('issue', 'Weak premium teaser')->count(), + ], + 'rows' => $rows->all(), + ]; + }); + } + + /** + * @param array $filters + * @return array + */ + public function getEditorialRecommendations(array $filters = []): array + { + return $this->remember('editorial-recommendations', $filters, function (Carbon $from, Carbon $to, int $limit): array { + $searchGaps = $this->getSearchGaps(['from' => $from, 'to' => $to, 'limit' => $limit]); + $promptInsights = $this->getPromptInsights(['from' => $from, 'to' => $to, 'limit' => $limit]); + $lessonDropoffs = $this->getLessonDropoffs(['from' => $from, 'to' => $to, 'limit' => $limit]); + $courseHealth = $this->getCourseHealth(['from' => $from, 'to' => $to, 'limit' => $limit]); + $premiumInterest = $this->getPremiumInterest(['from' => $from, 'to' => $to, 'limit' => $limit]); + + $recommendations = collect(); + + foreach (array_slice($searchGaps['zero_result_searches'], 0, 5) as $row) { + $recommendations->push([ + 'title' => sprintf('Create content for "%s"', $row['query']), + 'description' => sprintf('Users searched for "%s" %d times and saw %.1f results.', $row['query'], $row['searches'], $row['results_count']), + 'reason' => 'Repeated zero-result searches indicate missing Academy content coverage.', + 'priority' => $row['searches'] >= 3 ? 'high' : 'medium', + 'priority_score' => $row['searches'] >= 3 ? 300 + $row['searches'] : 200 + $row['searches'], + 'content_type' => null, + 'content_id' => null, + 'metric_snapshot' => [ + 'searches' => $row['searches'], + 'results_count' => $row['results_count'], + 'clicks' => $row['clicks'], + ], + 'suggested_action' => 'Create content for this topic', + ]); + } + + foreach (array_slice($promptInsights['rows'], 0, 4) as $row) { + $recommendations->push([ + 'title' => sprintf('Review prompt "%s"', $row['title']), + 'description' => sprintf('%s with %d views, %d copies, and a %.1f%% copy rate.', $row['issue'], $row['views'], $row['prompt_copies'], $row['copy_rate']), + 'reason' => 'Prompt performance suggests either discoverability or quality improvements are needed.', + 'priority' => $row['priority'], + 'priority_score' => 180 + (int) $row['priority_score'], + 'content_type' => $row['content_type'], + 'content_id' => $row['content_id'], + 'metric_snapshot' => [ + 'views' => $row['views'], + 'copies' => $row['prompt_copies'], + 'copy_rate' => $row['copy_rate'], + 'upgrade_clicks' => $row['upgrade_clicks'], + ], + 'suggested_action' => $row['suggested_action'], + ]); + } + + foreach (array_slice($lessonDropoffs['rows'], 0, 4) as $row) { + $recommendations->push([ + 'title' => sprintf('Improve lesson "%s"', $row['title']), + 'description' => sprintf('%s with %d starts and a %.1f%% completion rate.', $row['issue'], $row['starts'], $row['completion_rate']), + 'reason' => 'Lesson funnel data shows where learners hesitate or drop off.', + 'priority' => $row['priority'], + 'priority_score' => 170 + (int) $row['priority_score'], + 'content_type' => $row['content_type'], + 'content_id' => $row['content_id'], + 'metric_snapshot' => [ + 'views' => $row['views'], + 'starts' => $row['starts'], + 'completions' => $row['completions'], + 'completion_rate' => $row['completion_rate'], + ], + 'suggested_action' => $row['suggested_action'], + ]); + } + + foreach (array_slice($courseHealth['rows'], 0, 4) as $row) { + $recommendations->push([ + 'title' => sprintf('Review course "%s"', $row['title']), + 'description' => sprintf('%s with a %.1f%% completion rate and %.1f%% average progress.', $row['issue'], $row['completion_rate'], $row['avg_progress']), + 'reason' => 'Course progression data highlights where sequencing or positioning may be blocking learners.', + 'priority' => $row['priority'], + 'priority_score' => 160 + (int) $row['priority_score'], + 'content_type' => $row['content_type'], + 'content_id' => $row['content_id'], + 'metric_snapshot' => [ + 'views' => $row['views'], + 'starts' => $row['starts'], + 'completions' => $row['completions'], + 'avg_progress' => $row['avg_progress'], + ], + 'suggested_action' => $row['suggested_action'], + ]); + } + + foreach (array_slice($premiumInterest['rows'], 0, 4) as $row) { + $recommendations->push([ + 'title' => sprintf('Use "%s" as a premium signal', $row['title']), + 'description' => sprintf('%s with %d preview views and %d upgrade clicks.', $row['issue'], $row['premium_preview_views'], $row['upgrade_clicks']), + 'reason' => 'Premium preview behavior shows which topics can sell subscriptions or need better teaser copy.', + 'priority' => $row['priority'], + 'priority_score' => 150 + (int) $row['priority_score'], + 'content_type' => $row['content_type'], + 'content_id' => $row['content_id'], + 'metric_snapshot' => [ + 'premium_preview_views' => $row['premium_preview_views'], + 'upgrade_clicks' => $row['upgrade_clicks'], + 'upgrade_rate' => $row['upgrade_rate'], + ], + 'suggested_action' => $row['suggested_action'], + ]); + } + + $rows = $recommendations + ->sortByDesc('priority_score') + ->take($limit) + ->values() + ->map(function (array $row): array { + unset($row['priority_score']); + + return $row; + }); + + return [ + 'summary' => [ + 'total' => $rows->count(), + 'high_priority' => $rows->where('priority', 'high')->count(), + 'medium_priority' => $rows->where('priority', 'medium')->count(), + 'low_priority' => $rows->where('priority', 'low')->count(), + ], + 'rows' => $rows->all(), + ]; + }); + } + + /** + * @param array $filters + * @return array{0: Carbon, 1: Carbon, 2: int} + */ + private function resolveFilters(array $filters): array + { + $from = ($filters['from'] ?? null) instanceof Carbon + ? $filters['from']->copy()->startOfDay() + : Carbon::parse((string) ($filters['from'] ?? now()->subDays(29)->toDateString()))->startOfDay(); + $to = ($filters['to'] ?? null) instanceof Carbon + ? $filters['to']->copy()->endOfDay() + : Carbon::parse((string) ($filters['to'] ?? now()->toDateString()))->endOfDay(); + $limit = max(1, min(50, (int) ($filters['limit'] ?? 25))); + + return [$from, $to, $limit]; + } + + /** + * @param array $filters + * @return array + */ + private function remember(string $suffix, array $filters, callable $callback): array + { + [$from, $to, $limit] = $this->resolveFilters($filters); + + return Cache::remember( + sprintf('academy_analytics_%s:%s:%s:%d', $suffix, $from->toDateString(), $to->toDateString(), $limit), + now()->addMinutes(10), + fn (): array => $callback($from, $to, $limit), + ); + } + + private function searchQuery(Carbon $from, Carbon $to): Builder + { + return AcademySearchLog::query() + ->whereBetween('created_at', [$from, $to]) + ->selectRaw('normalized_query, max(query) as query, count(*) as searches, avg(results_count) as avg_results_count, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks, max(created_at) as last_searched_at, sum(case when is_logged_in = 1 then 1 else 0 end) as logged_in_searches, sum(case when is_subscriber = 1 then 1 else 0 end) as subscriber_searches') + ->whereNotNull('normalized_query') + ->groupBy('normalized_query'); + } + + /** + * @return Collection> + */ + private function contentMetrics(Carbon $from, Carbon $to, string $contentType): Collection + { + return AcademyContentMetricDaily::query() + ->whereBetween('date', [$from->toDateString(), $to->toDateString()]) + ->where('content_type', $contentType) + ->selectRaw('content_type, content_id, sum(views) as views, sum(unique_visitors) as unique_visitors, sum(engaged_views) as engaged_views, sum(likes) as likes, sum(saves) as saves, sum(prompt_copies) as prompt_copies, sum(negative_prompt_copies) as negative_prompt_copies, sum(starts) as starts, sum(completions) as completions, sum(upgrade_clicks) as upgrade_clicks, sum(premium_preview_views) as premium_preview_views, sum(search_clicks) as search_clicks, sum(popularity_score) as popularity_score') + ->groupBy('content_type', 'content_id') + ->get() + ->map(function ($row) use ($contentType): array { + $contentId = (int) $row->content_id; + $uniqueVisitors = max(0, (int) ($row->unique_visitors ?? 0)); + $promptCopies = max(0, (int) ($row->prompt_copies ?? 0)); + $likes = max(0, (int) ($row->likes ?? 0)); + $saves = max(0, (int) ($row->saves ?? 0)); + $starts = max(0, (int) ($row->starts ?? 0)); + $completions = max(0, (int) ($row->completions ?? 0)); + $searchClicks = max(0, (int) ($row->search_clicks ?? 0)); + $premiumPreviewViews = max(0, (int) ($row->premium_preview_views ?? 0)); + $upgradeClicks = max(0, (int) ($row->upgrade_clicks ?? 0)); + + return [ + 'content_type' => $contentType, + 'content_id' => $contentId, + 'content_type_label' => (string) Str::of(str_replace('academy_', '', $contentType))->replace('_', ' ')->headline(), + 'title' => $this->resolver->title($contentType, $contentId), + 'views' => max(0, (int) ($row->views ?? 0)), + 'unique_visitors' => $uniqueVisitors, + 'engaged_views' => max(0, (int) ($row->engaged_views ?? 0)), + 'likes' => $likes, + 'saves' => $saves, + 'prompt_copies' => $promptCopies, + 'negative_prompt_copies' => max(0, (int) ($row->negative_prompt_copies ?? 0)), + 'starts' => $starts, + 'completions' => $completions, + 'search_clicks' => $searchClicks, + 'premium_preview_views' => $premiumPreviewViews, + 'upgrade_clicks' => $upgradeClicks, + 'popularity_score' => round((float) ($row->popularity_score ?? 0), 2), + 'copy_rate' => $uniqueVisitors > 0 ? round(($promptCopies / $uniqueVisitors) * 100, 1) : 0.0, + 'save_rate' => $uniqueVisitors > 0 ? round(($saves / $uniqueVisitors) * 100, 1) : 0.0, + 'like_rate' => $uniqueVisitors > 0 ? round(($likes / $uniqueVisitors) * 100, 1) : 0.0, + 'search_click_rate' => $uniqueVisitors > 0 ? round(($searchClicks / $uniqueVisitors) * 100, 1) : 0.0, + 'start_rate' => $uniqueVisitors > 0 ? round(($starts / $uniqueVisitors) * 100, 1) : 0.0, + 'completion_rate' => $starts > 0 ? round(($completions / $starts) * 100, 1) : 0.0, + 'engagement_rate' => $uniqueVisitors > 0 ? round((((int) ($row->engaged_views ?? 0)) / $uniqueVisitors) * 100, 1) : 0.0, + 'upgrade_rate' => $premiumPreviewViews > 0 ? round(($upgradeClicks / $premiumPreviewViews) * 100, 1) : 0.0, + ]; + }); + } + + /** + * @return array{issue: string|null, priority: string, priority_score: int, suggested_action: string} + */ + private function classifySearchGap(int $searches, float $resultsCount, int $clicks, float $ctr, int $loggedInSearches, int $subscriberSearches): array + { + if ($resultsCount <= 0.4) { + return [ + 'issue' => 'Zero-result demand', + 'priority' => $searches >= 3 || $subscriberSearches >= 2 ? 'high' : 'medium', + 'priority_score' => 300 + $searches, + 'suggested_action' => 'Create content for this topic', + ]; + } + + if ($resultsCount > 0 && $clicks === 0) { + return [ + 'issue' => 'Results with no clicks', + 'priority' => $searches >= 3 || $loggedInSearches >= 2 ? 'high' : 'medium', + 'priority_score' => 240 + $searches, + 'suggested_action' => 'Improve titles, excerpts, thumbnails, or relevance', + ]; + } + + if ($searches >= 2 && $ctr < 10) { + return [ + 'issue' => 'Low click-through rate', + 'priority' => 'medium', + 'priority_score' => 180 + $searches, + 'suggested_action' => 'Improve matching content or create better content', + ]; + } + + if ($searches >= 2 && $ctr >= 40) { + return [ + 'issue' => 'High click-through topic', + 'priority' => 'medium', + 'priority_score' => 140 + $searches, + 'suggested_action' => 'Consider expanding this topic', + ]; + } + + return [ + 'issue' => null, + 'priority' => 'low', + 'priority_score' => 0, + 'suggested_action' => 'Monitor search intent', + ]; + } + + /** + * @param array $row + * @return array|null + */ + private function classifyPromptInsight(array $row): ?array + { + if ((int) $row['upgrade_clicks'] >= 3 || ((float) $row['upgrade_rate'] >= 15 && (int) $row['premium_preview_views'] >= 5)) { + return [ + 'issue' => 'High upgrade interest', + 'priority' => 'high', + 'priority_score' => 300 + (int) $row['upgrade_clicks'], + 'suggested_action' => 'Create premium pack or advanced lesson around this topic', + ]; + } + + if ((int) $row['views'] >= 120 && (float) $row['copy_rate'] < 8) { + return [ + 'issue' => 'High views, low copies', + 'priority' => 'medium', + 'priority_score' => 230 + (int) $row['views'], + 'suggested_action' => 'Improve prompt quality, preview image, title, or negative prompt', + ]; + } + + if ((int) $row['views'] <= 30 && (int) $row['prompt_copies'] >= 3 && (float) $row['copy_rate'] >= 35) { + return [ + 'issue' => 'Low views, high copy rate', + 'priority' => 'medium', + 'priority_score' => 210 + (int) $row['prompt_copies'], + 'suggested_action' => 'Feature this prompt, improve SEO, add to related content', + ]; + } + + if ((int) $row['saves'] >= 5 && (int) $row['prompt_copies'] < (int) $row['saves']) { + return [ + 'issue' => 'High saves, low copies', + 'priority' => 'medium', + 'priority_score' => 190 + (int) $row['saves'], + 'suggested_action' => 'Add examples, variations, or usage notes', + ]; + } + + if ((int) $row['prompt_copies'] >= 8 && (float) $row['like_rate'] < 5) { + return [ + 'issue' => 'High copies, low likes', + 'priority' => 'low', + 'priority_score' => 160 + (int) $row['prompt_copies'], + 'suggested_action' => 'Improve like/save UI visibility or ask for feedback', + ]; + } + + return null; + } + + /** + * @param array $row + * @return array|null + */ + private function classifyLessonDropoff(array $row): ?array + { + if ((int) $row['starts'] >= 12 && (float) $row['completion_rate'] < 35) { + return [ + 'issue' => 'High starts, low completions', + 'priority' => 'high', + 'priority_score' => 300 + (int) $row['starts'], + 'suggested_action' => 'Lesson may be too long, confusing, or missing examples', + ]; + } + + if ((int) $row['views'] >= 80 && (float) $row['start_rate'] < 18) { + return [ + 'issue' => 'High views, low starts', + 'priority' => 'medium', + 'priority_score' => 230 + (int) $row['views'], + 'suggested_action' => 'Improve lesson intro, title, excerpt, or call-to-action', + ]; + } + + if ((int) $row['completions'] >= 8 && (int) $row['views'] <= 35) { + return [ + 'issue' => 'High completions, low views', + 'priority' => 'medium', + 'priority_score' => 200 + (int) $row['completions'], + 'suggested_action' => 'Promote this lesson more', + ]; + } + + if ((int) $row['upgrade_clicks'] >= 3 || ((float) $row['upgrade_rate'] >= 12 && (int) $row['premium_preview_views'] >= 5)) { + return [ + 'issue' => 'Upgrade interest', + 'priority' => 'medium', + 'priority_score' => 180 + (int) $row['upgrade_clicks'], + 'suggested_action' => 'This lesson may be useful as a subscription conversion entry point', + ]; + } + + return null; + } + + /** + * @param array $row + * @return array|null + */ + private function classifyCourseHealth(array $row): ?array + { + if ((int) $row['starts'] >= 10 && (float) $row['completion_rate'] < 35) { + return [ + 'issue' => 'Low course completion rate', + 'priority' => 'high', + 'priority_score' => 300 + (int) $row['starts'], + 'suggested_action' => 'Add shorter lessons, move the strongest lesson earlier, or improve examples', + ]; + } + + if ((int) $row['views'] >= 60 && (float) $row['start_rate'] < 18) { + return [ + 'issue' => 'Low course start rate', + 'priority' => 'medium', + 'priority_score' => 220 + (int) $row['views'], + 'suggested_action' => 'Improve course landing page, cover image, or course positioning', + ]; + } + + if ((int) $row['upgrade_clicks'] >= 3) { + return [ + 'issue' => 'Premium follow-up opportunity', + 'priority' => 'medium', + 'priority_score' => 190 + (int) $row['upgrade_clicks'], + 'suggested_action' => 'Add a premium follow-up course around this topic', + ]; + } + + if ((int) $row['completions'] >= 8 && (float) $row['completion_rate'] >= 65) { + return [ + 'issue' => 'Expansion candidate', + 'priority' => 'medium', + 'priority_score' => 170 + (int) $row['completions'], + 'suggested_action' => 'Expand this course with advanced follow-up material', + ]; + } + + return null; + } + + /** + * @param array $row + * @return array|null + */ + private function classifyPremiumInterest(array $row): ?array + { + if ((int) $row['upgrade_clicks'] >= 3) { + return [ + 'issue' => 'Strong premium candidate', + 'priority' => 'high', + 'priority_score' => 300 + (int) $row['upgrade_clicks'], + 'suggested_action' => 'Create advanced premium content around this topic', + ]; + } + + if ((int) $row['premium_preview_views'] >= 15 && (int) $row['upgrade_clicks'] <= 1) { + return [ + 'issue' => 'Weak premium teaser', + 'priority' => 'medium', + 'priority_score' => 190 + (int) $row['premium_preview_views'], + 'suggested_action' => 'Improve teaser copy, preview images, or value proposition', + ]; + } + + return null; + } + + /** + * @param Collection> $rows + * @return Collection> + */ + private function dedupeByKey(Collection $rows, string $key): Collection + { + $seen = []; + + return $rows->filter(function (array $row) use (&$seen, $key): bool { + $value = (string) ($row[$key] ?? ''); + + if ($value === '' || isset($seen[$value])) { + return false; + } + + $seen[$value] = true; + + return true; + }); + } +} diff --git a/app/Services/Academy/AcademyInteractionService.php b/app/Services/Academy/AcademyInteractionService.php new file mode 100644 index 00000000..b0f91061 --- /dev/null +++ b/app/Services/Academy/AcademyInteractionService.php @@ -0,0 +1,171 @@ + + */ + public function toggleLike(User $user, string $contentType, int $contentId, ?Request $request = null): array + { + $this->assertSupportedContent($contentType, $contentId); + + $existing = AcademyLike::query() + ->where('user_id', $user->id) + ->where('content_type', $contentType) + ->where('content_id', $contentId) + ->first(); + + if ($existing) { + $existing->delete(); + $liked = false; + } else { + AcademyLike::query()->create([ + 'user_id' => $user->id, + 'content_type' => $contentType, + 'content_id' => $contentId, + ]); + $liked = true; + + if ($contentType === AcademyAnalyticsContentType::PROMPT) { + $this->analytics->track([ + 'event_type' => AcademyAnalyticsEventType::PROMPT_LIKE, + 'content_type' => $contentType, + 'content_id' => $contentId, + ], $user, $request); + } + } + + return [ + 'liked' => $liked, + 'likes_count' => $this->likesCount($contentType, $contentId), + ]; + } + + /** + * @return array + */ + public function toggleSave(User $user, string $contentType, int $contentId, ?Request $request = null): array + { + $content = $this->assertSupportedContent($contentType, $contentId); + + if ($contentType === AcademyAnalyticsContentType::PROMPT && $content instanceof AcademyPromptTemplate) { + $existing = AcademySave::query() + ->where('user_id', $user->id) + ->where('content_type', $contentType) + ->where('content_id', $contentId) + ->exists(); + + if ($existing) { + $this->progress->unsavePrompt($user, $content); + $saved = false; + } else { + $this->progress->savePrompt($user, $content); + $saved = true; + } + + return [ + 'saved' => $saved, + 'saves_count' => $this->savesCount($contentType, $contentId), + ]; + } + + $existing = AcademySave::query() + ->where('user_id', $user->id) + ->where('content_type', $contentType) + ->where('content_id', $contentId) + ->first(); + + if ($existing) { + $existing->delete(); + $saved = false; + } else { + AcademySave::query()->create([ + 'user_id' => $user->id, + 'content_type' => $contentType, + 'content_id' => $contentId, + ]); + $saved = true; + } + + return [ + 'saved' => $saved, + 'saves_count' => $this->savesCount($contentType, $contentId), + ]; + } + + /** + * @return array + */ + public function getInteractionState(?User $user, string $contentType, int $contentId): array + { + $this->assertSupportedContent($contentType, $contentId); + + return [ + 'liked' => $user + ? AcademyLike::query()->where('user_id', $user->id)->where('content_type', $contentType)->where('content_id', $contentId)->exists() + : false, + 'saved' => $user + ? AcademySave::query()->where('user_id', $user->id)->where('content_type', $contentType)->where('content_id', $contentId)->exists() + : false, + 'likes_count' => $this->likesCount($contentType, $contentId), + 'saves_count' => $this->savesCount($contentType, $contentId), + ]; + } + + public function likesCount(string $contentType, int $contentId): int + { + return AcademyLike::query() + ->where('content_type', $contentType) + ->where('content_id', $contentId) + ->count(); + } + + public function savesCount(string $contentType, int $contentId): int + { + return AcademySave::query() + ->where('content_type', $contentType) + ->where('content_id', $contentId) + ->count(); + } + + private function assertSupportedContent(string $contentType, int $contentId): mixed + { + if (! in_array($contentType, [ + AcademyAnalyticsContentType::PROMPT, + AcademyAnalyticsContentType::LESSON, + AcademyAnalyticsContentType::COURSE, + AcademyAnalyticsContentType::PROMPT_PACK, + AcademyAnalyticsContentType::CHALLENGE, + ], true)) { + throw new InvalidArgumentException('Unsupported Academy interaction content type.'); + } + + $content = $this->contentResolver->resolve($contentType, $contentId); + + if ($content === null) { + throw new InvalidArgumentException('Unknown Academy interaction content target.'); + } + + return $content; + } +} \ No newline at end of file diff --git a/app/Services/Academy/AcademyPopularityService.php b/app/Services/Academy/AcademyPopularityService.php new file mode 100644 index 00000000..78333120 --- /dev/null +++ b/app/Services/Academy/AcademyPopularityService.php @@ -0,0 +1,60 @@ + $metrics + */ + public function calculatePopularityScore(array $metrics): float + { + return round( + ((float) ($metrics['unique_visitors'] ?? 0) * 1) + + ((float) ($metrics['engaged_views'] ?? 0) * 3) + + ((float) ($metrics['likes'] ?? 0) * 5) + + ((float) ($metrics['saves'] ?? 0) * 7) + + ((float) ($metrics['prompt_copies'] ?? 0) * 8) + + ((float) ($metrics['negative_prompt_copies'] ?? 0) * 4) + + ((float) ($metrics['starts'] ?? 0) * 4) + + ((float) ($metrics['completions'] ?? 0) * 10) + + ((float) ($metrics['upgrade_clicks'] ?? 0) * 15) + + ((float) ($metrics['premium_preview_views'] ?? 0) * 3) + - ((float) ($metrics['bounce_count'] ?? 0) * 2), + 2, + ); + } + + /** + * @param array $metrics + */ + public function calculateConversionScore(array $metrics): float + { + $uniqueVisitors = max(1, (int) ($metrics['unique_visitors'] ?? 0)); + + return round((((float) ($metrics['upgrade_clicks'] ?? 0) * 100) / $uniqueVisitors), 2); + } + + public function queryBetween(Carbon $from, Carbon $to): Builder + { + return AcademyContentMetricDaily::query() + ->whereBetween('date', [$from->toDateString(), $to->toDateString()]); + } + + public function topContent(Carbon $from, Carbon $to, int $limit = 10): Collection + { + return $this->queryBetween($from, $to) + ->selectRaw('content_type, content_id, sum(views) as views, sum(unique_visitors) as unique_visitors, sum(engaged_views) as engaged_views, sum(likes) as likes, sum(saves) as saves, sum(prompt_copies) as prompt_copies, sum(completions) as completions, sum(upgrade_clicks) as upgrade_clicks, sum(popularity_score) as popularity_score') + ->groupBy('content_type', 'content_id') + ->orderByDesc('popularity_score') + ->limit($limit) + ->get(); + } +} \ No newline at end of file diff --git a/app/Services/Academy/AcademyProgressService.php b/app/Services/Academy/AcademyProgressService.php index 13c916db..7a71c7e9 100644 --- a/app/Services/Academy/AcademyProgressService.php +++ b/app/Services/Academy/AcademyProgressService.php @@ -9,17 +9,120 @@ use App\Models\AcademyLesson; use App\Models\AcademyLessonProgress; use App\Models\AcademyPromptTemplate; use App\Models\AcademySavedPrompt; +use App\Models\AcademySave; +use App\Models\AcademyUserProgress; use App\Models\User; +use App\Support\AcademyAnalytics\AcademyAnalyticsContentType; +use App\Support\AcademyAnalytics\AcademyAnalyticsEventType; +use App\Support\AcademyAnalytics\AcademyAnalyticsProgressStatus; +use Illuminate\Http\Request; final class AcademyProgressService { public function __construct( private readonly AcademyBadgeService $badges, private readonly AcademyCourseProgressService $courses, + private readonly AcademyAnalyticsService $analytics, ) { } - public function markLessonComplete(User $user, AcademyLesson $lesson, ?AcademyCourse $course = null): AcademyLessonProgress + public function startLesson(User $user, int $lessonId, ?int $courseId = null, ?Request $request = null): AcademyUserProgress + { + $progress = $this->updateUserProgressRecord($user, $courseId, $lessonId, [ + 'status' => AcademyAnalyticsProgressStatus::STARTED, + 'progress_percent' => 0, + 'started_at' => now(), + 'completed_at' => null, + 'last_seen_at' => now(), + ]); + + if ($courseId) { + $course = AcademyCourse::query()->find($courseId); + if ($course instanceof AcademyCourse) { + $this->courses->markEnrollmentStarted($user, $course); + $lesson = AcademyLesson::query()->find($lessonId); + if ($lesson instanceof AcademyLesson) { + $this->courses->updateLastLesson($user, $course, $lesson); + } + $this->syncCourseProgressRecord($user, $course); + } + } + + $this->analytics->track([ + 'event_type' => AcademyAnalyticsEventType::LESSON_STARTED, + 'content_type' => AcademyAnalyticsContentType::LESSON, + 'content_id' => $lessonId, + 'metadata' => array_filter([ + 'course_id' => $courseId, + ], static fn (mixed $value): bool => $value !== null), + ], $user, $request); + + return $progress; + } + + public function completeLesson(User $user, int $lessonId, ?int $courseId = null, ?Request $request = null): AcademyUserProgress + { + $progress = $this->updateUserProgressRecord($user, $courseId, $lessonId, [ + 'status' => AcademyAnalyticsProgressStatus::COMPLETED, + 'progress_percent' => 100, + 'started_at' => now(), + 'completed_at' => now(), + 'last_seen_at' => now(), + ]); + + if ($courseId) { + $course = AcademyCourse::query()->find($courseId); + if ($course instanceof AcademyCourse) { + $this->syncCourseProgressRecord($user, $course); + } + } + + $this->analytics->track([ + 'event_type' => AcademyAnalyticsEventType::LESSON_COMPLETED, + 'content_type' => AcademyAnalyticsContentType::LESSON, + 'content_id' => $lessonId, + 'metadata' => array_filter([ + 'course_id' => $courseId, + ], static fn (mixed $value): bool => $value !== null), + ], $user, $request); + + return $progress; + } + + public function startCourse(User $user, int $courseId, ?Request $request = null): AcademyUserProgress + { + $course = AcademyCourse::query()->findOrFail($courseId); + $this->courses->markEnrollmentStarted($user, $course); + + $progress = $this->syncCourseProgressRecord($user, $course, true); + + $this->analytics->track([ + 'event_type' => AcademyAnalyticsEventType::COURSE_STARTED, + 'content_type' => AcademyAnalyticsContentType::COURSE, + 'content_id' => $courseId, + ], $user, $request); + + return $progress; + } + + public function completeCourse(User $user, int $courseId, ?Request $request = null): AcademyUserProgress + { + $course = AcademyCourse::query()->findOrFail($courseId); + $this->courses->markCourseCompletedIfFinished($user, $course); + $progress = $this->syncCourseProgressRecord($user, $course); + + if ($progress->status === AcademyAnalyticsProgressStatus::COMPLETED) { + $this->analytics->track([ + 'event_type' => AcademyAnalyticsEventType::COURSE_COMPLETED, + 'content_type' => AcademyAnalyticsContentType::COURSE, + 'content_id' => $courseId, + ], $user, $request); + } + + return $progress; + } + + public function markLessonComplete(User $user, AcademyLesson $lesson, ?AcademyCourse $course = null, ?Request $request = null): AcademyLessonProgress { $progress = AcademyLessonProgress::query()->updateOrCreate( [ @@ -36,6 +139,8 @@ final class AcademyProgressService $this->courses->markCourseCompletedIfFinished($user, $course); } + $this->completeLesson($user, (int) $lesson->id, $course?->id, $request); + $this->badges->syncForUser($user); return $progress; @@ -48,6 +153,18 @@ final class AcademyProgressService 'user_id' => $user->id, ]); + AcademySave::query()->firstOrCreate([ + 'user_id' => $user->id, + 'content_type' => AcademyAnalyticsContentType::PROMPT, + 'content_id' => $prompt->id, + ]); + + $this->analytics->track([ + 'event_type' => AcademyAnalyticsEventType::PROMPT_SAVE, + 'content_type' => AcademyAnalyticsContentType::PROMPT, + 'content_id' => (int) $prompt->id, + ], $user); + $this->badges->syncForUser($user); return $saved; @@ -60,6 +177,50 @@ final class AcademyProgressService ->where('user_id', $user->id) ->delete(); + AcademySave::query() + ->where('user_id', $user->id) + ->where('content_type', AcademyAnalyticsContentType::PROMPT) + ->where('content_id', $prompt->id) + ->delete(); + $this->badges->syncForUser($user); } + + /** + * @param array $attributes + */ + private function updateUserProgressRecord(User $user, ?int $courseId, ?int $lessonId, array $attributes): AcademyUserProgress + { + return AcademyUserProgress::query()->updateOrCreate( + [ + 'user_id' => $user->id, + 'course_id' => $courseId, + 'lesson_id' => $lessonId, + ], + $attributes, + ); + } + + private function syncCourseProgressRecord(User $user, AcademyCourse $course, bool $forceStarted = false): AcademyUserProgress + { + $progressPercent = $this->courses->getProgressPercent($user, $course); + $isComplete = $this->courses->getTotalRequiredLessonsCount($course) > 0 && $progressPercent >= 100; + $status = $isComplete + ? AcademyAnalyticsProgressStatus::COMPLETED + : ($progressPercent > 0 || $forceStarted + ? AcademyAnalyticsProgressStatus::IN_PROGRESS + : AcademyAnalyticsProgressStatus::STARTED); + + return $this->updateUserProgressRecord($user, (int) $course->id, null, [ + 'status' => $status, + 'progress_percent' => $progressPercent, + 'started_at' => now(), + 'completed_at' => $isComplete ? now() : null, + 'last_seen_at' => now(), + 'metadata' => [ + 'completed_required' => $this->courses->getCompletedRequiredLessonsCount($user, $course), + 'total_required' => $this->courses->getTotalRequiredLessonsCount($course), + ], + ]); + } } \ No newline at end of file diff --git a/app/Services/Academy/AcademyStripeWebhookAuditService.php b/app/Services/Academy/AcademyStripeWebhookAuditService.php new file mode 100644 index 00000000..bbff64bd --- /dev/null +++ b/app/Services/Academy/AcademyStripeWebhookAuditService.php @@ -0,0 +1,298 @@ + $payload + */ + public function recordReceived(array $payload): void + { + $context = $this->buildContext($payload); + $tracked = in_array($context['event_type'], self::TRACKED_EVENT_TYPES, true); + $cacheKeys = []; + + if ($tracked && $context['user'] instanceof User) { + $cacheKeys = [ + 'academy.billing.account.'.$context['user']->id, + 'academy.billing.pricing.'.$context['user']->id, + ]; + + foreach ($cacheKeys as $cacheKey) { + Cache::forget($cacheKey); + } + } + + $event = $this->persistEvent($context, [ + 'received' => true, + 'received_at' => now()->toISOString(), + 'tracked' => $tracked, + 'action' => $tracked ? 'received_for_cashier_processing' : 'ignored_untracked_event', + 'user_resolved' => $context['user'] instanceof User, + 'cache_cleared' => $cacheKeys !== [], + 'cache_keys' => $cacheKeys, + 'status' => $context['object']['status'] ?? null, + 'mode' => $context['object']['mode'] ?? null, + 'amount_total' => $context['object']['amount_total'] ?? null, + 'currency' => $context['object']['currency'] ?? null, + 'price_ids' => $this->extractPriceIds($context['object']), + ]); + + Log::info('academy.stripe.webhook.received', [ + 'stripe_event_id' => $context['event_id'], + 'event_type' => $context['event_type'], + 'tracked' => $tracked, + 'user_id' => $context['user']?->id, + 'academy_plan' => $context['plan']['key'] ?? null, + 'academy_tier' => $context['plan']['tier'] ?? null, + 'audit_event_id' => $event->id, + ]); + } + + /** + * @param array $payload + */ + public function recordHandled(array $payload): void + { + $context = $this->buildContext($payload); + $localSubscription = $this->resolveLocalSubscription($context['subscription_id'], $context['user']); + + $outcome = $localSubscription instanceof Subscription + ? 'local_subscription_synced' + : 'handled_without_local_subscription_change'; + + $event = $this->persistEvent($context, [ + 'handled' => true, + 'handled_at' => now()->toISOString(), + 'outcome' => $outcome, + 'local_subscription_found' => $localSubscription instanceof Subscription, + 'local_subscription_status' => $localSubscription?->stripe_status, + 'local_subscription_active' => $localSubscription?->active(), + 'local_subscription_on_grace_period' => $localSubscription?->onGracePeriod(), + 'local_price_ids' => $localSubscription instanceof Subscription + ? $localSubscription->items->pluck('stripe_price')->filter()->values()->all() + : [], + ]); + + Log::info('academy.stripe.webhook.handled', [ + 'stripe_event_id' => $context['event_id'], + 'event_type' => $context['event_type'], + 'user_id' => $context['user']?->id, + 'academy_plan' => $context['plan']['key'] ?? null, + 'academy_tier' => $context['plan']['tier'] ?? null, + 'outcome' => $outcome, + 'audit_event_id' => $event->id, + ]); + } + + /** + * @param array $payload + * @return array{event_id:string,event_type:string,object:array,customer_id:?string,subscription_id:?string,plan:?array,user:?User} + */ + private function buildContext(array $payload): array + { + $eventType = trim((string) ($payload['type'] ?? '')); + $object = is_array($payload['data']['object'] ?? null) + ? $payload['data']['object'] + : []; + + $customerId = $this->extractCustomerId($object); + $subscriptionId = $this->extractSubscriptionId($object); + $plan = $this->resolvePlan($object); + $user = $this->resolveUser($customerId, $subscriptionId, $object); + + return [ + 'event_id' => trim((string) ($payload['id'] ?? '')), + 'event_type' => $eventType, + 'object' => $object, + 'customer_id' => $customerId, + 'subscription_id' => $subscriptionId, + 'plan' => $plan, + 'user' => $user, + ]; + } + + /** + * @param array $context + * @param array $summary + */ + private function persistEvent(array $context, array $summary): AcademyBillingEvent + { + $eventId = $context['event_id']; + + $event = $eventId !== '' + ? AcademyBillingEvent::query()->firstOrNew(['stripe_event_id' => $eventId]) + : new AcademyBillingEvent(); + + $existingSummary = is_array($event->payload_summary) ? $event->payload_summary : []; + + $event->fill([ + 'user_id' => $context['user']?->id, + 'stripe_event_id' => $eventId !== '' ? $eventId : null, + 'stripe_customer_id' => $context['customer_id'], + 'stripe_subscription_id' => $context['subscription_id'], + 'event_type' => $context['event_type'] !== '' ? $context['event_type'] : 'unknown', + 'academy_tier' => $context['plan']['tier'] ?? null, + 'academy_plan' => $context['plan']['key'] ?? null, + 'payload_summary' => array_merge($existingSummary, $summary), + 'processed_at' => now(), + ]); + + $event->save(); + + return $event; + } + + /** + * @param array $object + * @return array|null + */ + private function resolvePlan(array $object): ?array + { + $metadataPlan = trim((string) Arr::get($object, 'metadata.academy_plan', '')); + + if ($metadataPlan !== '') { + return $this->plans->plan($metadataPlan); + } + + foreach ($this->extractPriceIds($object) as $priceId) { + $plan = $this->plans->planForPriceId($priceId); + + if ($plan !== null) { + return $plan; + } + } + + return null; + } + + /** + * @param array $object + * @return list + */ + private function extractPriceIds(array $object): array + { + $priceIds = []; + + foreach ((array) Arr::get($object, 'items.data', []) as $item) { + if (! is_array($item)) { + continue; + } + + $priceId = trim((string) Arr::get($item, 'price.id', '')); + + if ($priceId !== '') { + $priceIds[] = $priceId; + } + } + + $lineItemPriceId = trim((string) Arr::get($object, 'display_items.0.price.id', '')); + + if ($lineItemPriceId !== '') { + $priceIds[] = $lineItemPriceId; + } + + return array_values(array_unique($priceIds)); + } + + /** + * @param array $object + */ + private function extractCustomerId(array $object): ?string + { + $value = trim((string) ($object['customer'] ?? '')); + + return $value !== '' ? $value : null; + } + + /** + * @param array $object + */ + private function extractSubscriptionId(array $object): ?string + { + $subscriptionId = trim((string) ($object['id'] ?? '')); + + if (str_starts_with($subscriptionId, 'sub_')) { + return $subscriptionId; + } + + $nested = trim((string) ($object['subscription'] ?? '')); + + return $nested !== '' ? $nested : null; + } + + /** + * @param array $object + */ + private function resolveUser(?string $customerId, ?string $subscriptionId, array $object): ?User + { + $metadataUserId = (int) Arr::get($object, 'metadata.user_id', 0); + + if ($metadataUserId > 0) { + return User::query()->find($metadataUserId); + } + + if ($customerId !== null) { + $user = User::query()->where('stripe_id', $customerId)->first(); + + if ($user instanceof User) { + return $user; + } + } + + if ($subscriptionId !== null) { + $subscription = Subscription::query()->where('stripe_id', $subscriptionId)->first(); + + if ($subscription !== null && $subscription->user instanceof User) { + return $subscription->user; + } + } + + return null; + } + + private function resolveLocalSubscription(?string $subscriptionId, ?User $user): ?Subscription + { + if ($subscriptionId !== null) { + $subscription = Subscription::query()->where('stripe_id', $subscriptionId)->with('items')->first(); + + if ($subscription instanceof Subscription) { + return $subscription; + } + } + + if (! $user instanceof User) { + return null; + } + + $subscription = $user->subscription($this->plans->subscriptionName()); + + return $subscription instanceof Subscription + ? $subscription->loadMissing('items') + : null; + } +} \ No newline at end of file diff --git a/app/Services/ArtworkService.php b/app/Services/ArtworkService.php index 8183a3ee..9eff2309 100644 --- a/app/Services/ArtworkService.php +++ b/app/Services/ArtworkService.php @@ -48,7 +48,8 @@ class ArtworkService 'user.profile:user_id,avatar_hash', 'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at', 'categories' => function ($q) { - $q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order'); + $q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') + ->with(['contentType:id,slug,name']); }, ]; } diff --git a/app/Services/News/NewsService.php b/app/Services/News/NewsService.php index b43eb8ef..be9f7d11 100644 --- a/app/Services/News/NewsService.php +++ b/app/Services/News/NewsService.php @@ -136,6 +136,8 @@ final class NewsService $categoryId = (int) ($filters['category_id'] ?? 0); $search = trim((string) ($filters['q'] ?? '')); $perPage = max(10, min(50, (int) ($filters['per_page'] ?? 15))); + $order = trim((string) ($filters['order'] ?? '')); + $direction = trim((string) ($filters['direction'] ?? '')); if ($status !== '') { $query->where('editorial_status', $status); @@ -158,6 +160,20 @@ final class NewsService }); } + if ($order !== '') { + $map = [ + 'date' => 'published_at', + 'title' => 'title', + 'views' => 'views', + ]; + + if (array_key_exists($order, $map)) { + $dir = in_array(Str::lower($direction), ['asc', 'desc'], true) ? Str::lower($direction) : 'desc'; + // Replace any existing ordering (editorialOrder) with the user-specified ordering. + $query->reorder($map[$order], $dir); + } + } + $paginator = $query->paginate($perPage)->withQueryString(); return [ @@ -169,6 +185,8 @@ final class NewsService 'type' => $type, 'category_id' => $categoryId > 0 ? $categoryId : '', 'per_page' => $perPage, + 'order' => $order, + 'direction' => in_array(Str::lower($direction), ['asc', 'desc'], true) ? Str::lower($direction) : '', ], ]; } @@ -179,7 +197,7 @@ final class NewsService return [ 'id' => (int) $article->id, - 'title' => (string) $article->title, + 'title' => $this->decodeLegacyHtml((string) $article->title), 'slug' => (string) $article->slug, 'excerpt' => (string) ($article->excerpt ?? ''), 'content' => (string) ($article->content ?? ''), @@ -421,6 +439,8 @@ final class NewsService $title = 'Untitled News Article'; } + $slug = $this->resolveSlug($title, $article, $data); + $previousCoverImage = trim((string) ($article->cover_image ?? '')); $editorialStatus = $this->normalizeEditorialStatus((string) ($data['editorial_status'] ?? $article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT)); @@ -429,7 +449,7 @@ final class NewsService $article->fill([ 'title' => $title, - 'slug' => $this->resolveSlug($title, $article, $data), + 'slug' => $slug, 'excerpt' => $this->nullableText($data['excerpt'] ?? null), 'content' => (string) ($data['content'] ?? ''), 'cover_image' => $this->nullableText($data['cover_image'] ?? null), @@ -445,7 +465,7 @@ final class NewsService 'meta_title' => $this->nullableText($data['meta_title'] ?? null), 'meta_description' => $this->nullableText($data['meta_description'] ?? null), 'meta_keywords' => $this->nullableText($data['meta_keywords'] ?? null), - 'canonical_url' => $this->nullableText($data['canonical_url'] ?? null), + 'canonical_url' => route('news.show', ['slug' => $slug]), 'og_title' => $this->nullableText($data['og_title'] ?? null), 'og_description' => $this->nullableText($data['og_description'] ?? null), 'og_image' => $this->nullableText($data['og_image'] ?? null), @@ -472,7 +492,7 @@ final class NewsService { return [ 'id' => (int) $article->id, - 'title' => (string) $article->title, + 'title' => $this->decodeLegacyHtml((string) $article->title), 'slug' => (string) $article->slug, 'type' => (string) ($article->type ?? NewsArticle::TYPE_ANNOUNCEMENT), 'type_label' => (string) $article->type_label, @@ -598,6 +618,23 @@ final class NewsService Storage::disk((string) config('uploads.object_storage.disk', 's3'))->delete($paths); } + private function decodeLegacyHtml(string $value): string + { + $decoded = $value; + + for ($pass = 0; $pass < 5; $pass++) { + $next = html_entity_decode($decoded, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + if ($next === $decoded) { + break; + } + + $decoded = $next; + } + + return str_replace(['´', '´'], ["'", "'"], $decoded); + } + private function searchGroups(string $query, ?User $viewer): array { return Group::query() diff --git a/app/Services/Recommendations/HybridSimilarArtworksService.php b/app/Services/Recommendations/HybridSimilarArtworksService.php index d217be2a..1aaf1adc 100644 --- a/app/Services/Recommendations/HybridSimilarArtworksService.php +++ b/app/Services/Recommendations/HybridSimilarArtworksService.php @@ -66,6 +66,13 @@ final class HybridSimilarArtworksService ->whereIn('id', $idSlice) ->public() ->published() + ->with([ + 'categories:id,slug,name,content_type_id', + 'categories.contentType:id,name,slug', + 'user:id,name,username', + 'user.profile:user_id,avatar_hash', + 'group:id,name,slug,avatar_path', + ]) ->get() ->keyBy('id'); diff --git a/app/Services/Sitemaps/Builders/StaticPagesSitemapBuilder.php b/app/Services/Sitemaps/Builders/StaticPagesSitemapBuilder.php index 92077b7f..ab3a766d 100644 --- a/app/Services/Sitemaps/Builders/StaticPagesSitemapBuilder.php +++ b/app/Services/Sitemaps/Builders/StaticPagesSitemapBuilder.php @@ -26,6 +26,7 @@ final class StaticPagesSitemapBuilder extends AbstractSitemapBuilder $this->urls->staticRoute('/'), $this->urls->staticRoute('/academy'), $this->urls->staticRoute('/academy/pricing'), + $this->urls->staticRoute('/web-stories'), $this->urls->staticRoute('/faq'), $this->urls->staticRoute('/rules-and-guidelines'), $this->urls->staticRoute('/privacy-policy'), diff --git a/app/Services/Sitemaps/Builders/WorldWebStoriesSitemapBuilder.php b/app/Services/Sitemaps/Builders/WorldWebStoriesSitemapBuilder.php new file mode 100644 index 00000000..e85c34f8 --- /dev/null +++ b/app/Services/Sitemaps/Builders/WorldWebStoriesSitemapBuilder.php @@ -0,0 +1,41 @@ +visible() + ->with('world') + ->orderByDesc('published_at') + ->orderByDesc('id') + ->get() + ->map(fn (WorldWebStory $story) => $this->urls->webStory($story)) + ->filter() + ->values() + ->all(); + } + + public function lastModified(): ?DateTimeInterface + { + return $this->dateTime(WorldWebStory::query()->visible()->max('updated_at')); + } +} \ No newline at end of file diff --git a/app/Services/Sitemaps/SitemapRegistry.php b/app/Services/Sitemaps/SitemapRegistry.php index 2be80bde..50cc2ff3 100644 --- a/app/Services/Sitemaps/SitemapRegistry.php +++ b/app/Services/Sitemaps/SitemapRegistry.php @@ -22,6 +22,7 @@ use App\Services\Sitemaps\Builders\StaticPagesSitemapBuilder; use App\Services\Sitemaps\Builders\StoriesSitemapBuilder; use App\Services\Sitemaps\Builders\TagsSitemapBuilder; use App\Services\Sitemaps\Builders\UsersSitemapBuilder; +use App\Services\Sitemaps\Builders\WorldWebStoriesSitemapBuilder; final class SitemapRegistry { @@ -43,6 +44,7 @@ final class SitemapRegistry CollectionsSitemapBuilder $collections, CardsSitemapBuilder $cards, StoriesSitemapBuilder $stories, + WorldWebStoriesSitemapBuilder $webStories, NewsSitemapBuilder $news, GoogleNewsSitemapBuilder $googleNews, ForumIndexSitemapBuilder $forumIndex, @@ -63,6 +65,7 @@ final class SitemapRegistry $collections->name() => $collections, $cards->name() => $cards, $stories->name() => $stories, + $webStories->name() => $webStories, $news->name() => $news, $googleNews->name() => $googleNews, $forumIndex->name() => $forumIndex, diff --git a/app/Services/Sitemaps/SitemapUrlBuilder.php b/app/Services/Sitemaps/SitemapUrlBuilder.php index 227758bc..cffb46a5 100644 --- a/app/Services/Sitemaps/SitemapUrlBuilder.php +++ b/app/Services/Sitemaps/SitemapUrlBuilder.php @@ -14,6 +14,7 @@ use App\Models\Page; use App\Models\Story; use App\Models\Tag; use App\Models\User; +use App\Models\WorldWebStory; use App\Services\ThumbnailPresenter; use cPad\Plugins\Forum\Models\ForumBoard; use cPad\Plugins\Forum\Models\ForumCategory; @@ -187,6 +188,21 @@ final class SitemapUrlBuilder extends AbstractSitemapBuilder ); } + public function webStory(WorldWebStory $story): ?SitemapUrl + { + if (trim((string) $story->slug) === '') { + return null; + } + + return new SitemapUrl( + $story->publicUrl(), + $this->newest($story->updated_at, $story->published_at, $story->created_at), + $this->images([ + $this->image($story->posterPortraitUrl(), (string) $story->title), + ]), + ); + } + public function forumIndex(): SitemapUrl { return new SitemapUrl(route('forum.index')); diff --git a/app/Services/WebStories/WorldWebStoryAssetService.php b/app/Services/WebStories/WorldWebStoryAssetService.php new file mode 100644 index 00000000..5f094200 --- /dev/null +++ b/app/Services/WebStories/WorldWebStoryAssetService.php @@ -0,0 +1,166 @@ +, pages: array>} + */ + public function buildAssets(WorldWebStory $story, bool $force = false, bool $dryRun = false): array + { + $story->loadMissing(['world', 'orderedPages.artwork']); + $world = $story->world; + $storyChanges = []; + $pageChanges = []; + + $primaryImage = $this->bestWorldImage($story); + + if (($force || blank($story->poster_portrait_path)) && filled($primaryImage)) { + $storyChanges['poster_portrait_path'] = $primaryImage; + } + + if (($force || blank($story->poster_square_path)) && filled($primaryImage)) { + $storyChanges['poster_square_path'] = $primaryImage; + } + + if ($force || blank($story->publisher_logo_path)) { + $storyChanges['publisher_logo_path'] = $this->defaultPublisherLogoPath(); + } + + foreach ($story->orderedPages as $page) { + $changes = []; + $background = $this->bestPageBackground($page, $world, $primaryImage); + + if (($force || blank($page->background_path)) && filled($background)) { + $changes['background_path'] = $background; + } + + if (($force || blank($page->background_mobile_path)) && filled($background)) { + $changes['background_mobile_path'] = $background; + } + + if (($force || blank($page->alt_text)) && filled($page->headline)) { + $changes['alt_text'] = (string) $page->headline; + } + + if ($changes !== []) { + $pageChanges[(int) $page->id] = $changes; + if (! $dryRun) { + $page->forceFill($changes)->save(); + } + } + } + + if ($storyChanges !== [] && ! $dryRun) { + $story->forceFill($storyChanges)->save(); + } + + return [ + 'updated' => $storyChanges !== [] || $pageChanges !== [], + 'story' => $storyChanges, + 'pages' => $pageChanges, + ]; + } + + public function storyBasePath(WorldWebStory $story): string + { + $slug = trim((string) ($story->world?->slug ?: $story->slug)); + + return 'web-stories/worlds/' . $slug; + } + + private function bestWorldImage(WorldWebStory $story): ?string + { + $world = $story->world; + + if ($world instanceof World) { + foreach ([$world->ogImageUrl(), $world->coverUrl(), $world->teaserImageUrl()] as $candidate) { + if (filled($candidate)) { + return (string) $candidate; + } + } + + $artwork = $this->bestWorldArtwork($world); + if ($artwork instanceof Artwork) { + return $this->artworkImage($artwork); + } + } + + return null; + } + + private function bestPageBackground(WorldWebStoryPage $page, ?World $world, ?string $fallback): ?string + { + if ($page->artwork instanceof Artwork) { + $artworkImage = $this->artworkImage($page->artwork); + if (filled($artworkImage)) { + return $artworkImage; + } + } + + if ($world instanceof World) { + $artwork = $this->bestWorldArtwork($world); + if ($artwork instanceof Artwork) { + $artworkImage = $this->artworkImage($artwork); + if (filled($artworkImage)) { + return $artworkImage; + } + } + } + + return $fallback; + } + + private function bestWorldArtwork(World $world): ?Artwork + { + $relatedArtworkIds = $world->worldRelations() + ->where('related_type', 'artwork') + ->orderByDesc('is_featured') + ->orderBy('sort_order') + ->pluck('related_id') + ->map(fn ($id) => (int) $id) + ->filter() + ->values(); + + if ($relatedArtworkIds->isNotEmpty()) { + return Artwork::query() + ->whereIn('id', $relatedArtworkIds) + ->get() + ->sortBy(fn (Artwork $artwork): int => (int) ($relatedArtworkIds->search((int) $artwork->id) ?? PHP_INT_MAX)) + ->first(); + } + + $submission = WorldSubmission::query() + ->with('artwork') + ->where('world_id', $world->id) + ->where('status', WorldSubmission::STATUS_LIVE) + ->orderByDesc('is_featured') + ->orderByDesc('featured_at') + ->orderByDesc('id') + ->first(); + + return $submission?->artwork; + } + + private function artworkImage(Artwork $artwork): ?string + { + $preview = ThumbnailPresenter::present($artwork, 'xl'); + + return (string) ($preview['url'] ?? $artwork->thumbnail_url ?? $artwork->thumb_url ?? ''); + } +} \ No newline at end of file diff --git a/app/Services/WebStories/WorldWebStoryGenerator.php b/app/Services/WebStories/WorldWebStoryGenerator.php new file mode 100644 index 00000000..e603ae70 --- /dev/null +++ b/app/Services/WebStories/WorldWebStoryGenerator.php @@ -0,0 +1,296 @@ +, warnings: list, page_count: int}} + */ + public function generateFromWorld(World $world, ?User $actor = null, int $pages = 7, bool $force = false, bool $publish = false, bool $dryRun = false): array + { + $pageCount = max(5, min(10, $pages)); + $existing = WorldWebStory::query()->where('world_id', $world->id)->orderByDesc('id')->first(); + + if ($existing && ! $force && ! $dryRun) { + throw ValidationException::withMessages([ + 'world' => ['A web story already exists for this world. Use --force to rebuild it.'], + ]); + } + + $selectedArtworks = $this->candidateArtworks($world)->take(max(3, $pageCount - 3))->values(); + $storyAttributes = [ + 'world_id' => $world->id, + 'slug' => $existing?->slug ?: $this->uniqueSlug($world->slug, $existing?->id), + 'title' => $existing?->title ?: (string) $world->title, + 'subtitle' => $world->tagline, + 'excerpt' => $world->summary ?: $world->tagline, + 'description' => $world->description ?: $world->summary, + 'seo_title' => trim((string) ($world->seo_title ?: ($world->title . ' – Skinbase Web Story'))), + 'seo_description' => trim((string) ($world->seo_description ?: $world->summary ?: $world->description ?: '')), + 'status' => WorldWebStory::STATUS_DRAFT, + 'active' => true, + 'noindex' => false, + 'featured' => false, + 'updated_by' => $actor?->id, + ]; + + if (! $existing) { + $storyAttributes['created_by'] = $actor?->id; + } + + $pagePayloads = $this->buildPagePayloads($world, $selectedArtworks, $pageCount); + + if ($dryRun) { + $story = $existing ?? new WorldWebStory($storyAttributes); + $story->fill($storyAttributes); + $story->setRelation('orderedPages', collect($pagePayloads)->map(fn (array $page): WorldWebStoryPage => new WorldWebStoryPage($page))); + + $this->assets->buildAssets($story, force: $force, dryRun: true); + $validation = $this->validation->validate($story); + + return [ + 'story' => $story, + 'created' => ! $existing, + 'validation' => $validation, + ]; + } + + $story = DB::transaction(function () use ($existing, $storyAttributes, $pagePayloads): WorldWebStory { + $story = $existing ?? new WorldWebStory(); + $story->fill($storyAttributes); + $story->save(); + + $story->pages()->delete(); + + foreach ($pagePayloads as $pagePayload) { + $story->pages()->create($pagePayload); + } + + return $story->fresh(['orderedPages', 'world']); + }); + + $this->assets->buildAssets($story, force: $force); + $story->refresh()->load('orderedPages', 'world'); + + if ($publish) { + $this->validation->assertPublishable($story); + $story->forceFill([ + 'status' => WorldWebStory::STATUS_PUBLISHED, + 'published_at' => now(), + ])->save(); + } + + return [ + 'story' => $story->fresh(['orderedPages', 'world']), + 'created' => ! $existing, + 'validation' => $this->validation->validate($story), + ]; + } + + /** + * @return Collection + */ + private function candidateArtworks(World $world): Collection + { + $relationIds = $world->worldRelations() + ->where('related_type', 'artwork') + ->orderByDesc('is_featured') + ->orderBy('sort_order') + ->pluck('related_id') + ->map(fn ($id): int => (int) $id) + ->filter() + ->values(); + + $artworks = collect(); + + if ($relationIds->isNotEmpty()) { + $artworks = Artwork::query() + ->whereIn('id', $relationIds) + ->get() + ->sortBy(fn (Artwork $artwork): int => $relationIds->search((int) $artwork->id)) + ->values(); + } + + if ($artworks->count() < 3) { + $submissionArtworks = WorldSubmission::query() + ->with('artwork.user') + ->where('world_id', $world->id) + ->where('status', WorldSubmission::STATUS_LIVE) + ->orderByDesc('is_featured') + ->orderByDesc('featured_at') + ->orderByDesc('id') + ->get() + ->pluck('artwork') + ->filter(fn ($artwork): bool => $artwork instanceof Artwork); + + $artworks = $artworks->concat($submissionArtworks)->unique(fn (Artwork $artwork): int => (int) $artwork->id)->values(); + } + + return $artworks; + } + + /** + * @param Collection $artworks + * @return list> + */ + private function buildPagePayloads(World $world, Collection $artworks, int $pageCount): array + { + $primaryArtwork = $artworks->get(0); + $secondaryArtwork = $artworks->get(1) ?: $primaryArtwork; + $tertiaryArtwork = $artworks->get(2) ?: $secondaryArtwork; + + $pages = [ + [ + 'position' => 1, + 'layout' => WorldWebStoryPage::LAYOUT_COVER, + 'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE, + 'headline' => (string) $world->title, + 'body' => Str::limit((string) ($world->tagline ?: $world->summary ?: 'A cinematic Skinbase World.'), 160, ''), + 'caption' => 'Skinbase World', + 'alt_text' => (string) $world->title, + 'text_position' => 'bottom', + 'overlay_strength' => 45, + 'animation' => 'fade-in', + 'active' => true, + ], + [ + 'position' => 2, + 'layout' => WorldWebStoryPage::LAYOUT_MOOD, + 'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE, + 'headline' => 'Step into ' . $world->title, + 'body' => Str::limit((string) ($world->summary ?: $world->description ?: 'Curated visuals, featured creators, and a clear editorial mood.'), 170, ''), + 'caption' => 'World intro', + 'alt_text' => 'Intro for ' . $world->title, + 'text_position' => 'bottom', + 'overlay_strength' => 35, + 'animation' => 'fly-in-bottom', + 'active' => true, + ], + ]; + + if ($primaryArtwork instanceof Artwork) { + $pages[] = [ + 'position' => count($pages) + 1, + 'layout' => WorldWebStoryPage::LAYOUT_ARTWORK, + 'artwork_id' => $primaryArtwork->id, + 'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE, + 'headline' => (string) ($primaryArtwork->title ?: 'Featured artwork'), + 'body' => Str::limit('A featured visual from ' . $world->title . ' by ' . ($primaryArtwork->user?->name ?: $primaryArtwork->user?->username ?: 'a Skinbase creator') . '.', 160, ''), + 'caption' => 'Featured artwork', + 'alt_text' => (string) ($primaryArtwork->title ?: 'Featured artwork'), + 'text_position' => 'bottom', + 'overlay_strength' => 35, + 'animation' => 'pan-left', + 'active' => true, + ]; + } + + if ($secondaryArtwork instanceof Artwork) { + $pages[] = [ + 'position' => count($pages) + 1, + 'layout' => WorldWebStoryPage::LAYOUT_CREATOR, + 'artwork_id' => $secondaryArtwork->id, + 'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE, + 'headline' => 'Creator spotlight', + 'body' => Str::limit(($secondaryArtwork->user?->name ?: $secondaryArtwork->user?->username ?: 'A featured creator') . ' helps define the mood of ' . $world->title . '.', 160, ''), + 'caption' => 'Creator spotlight', + 'alt_text' => (string) ($secondaryArtwork->title ?: 'Creator spotlight artwork'), + 'text_position' => 'bottom', + 'overlay_strength' => 40, + 'animation' => 'fade-in', + 'active' => true, + ]; + } + + if ($tertiaryArtwork instanceof Artwork) { + $pages[] = [ + 'position' => count($pages) + 1, + 'layout' => WorldWebStoryPage::LAYOUT_COLLECTION, + 'artwork_id' => $tertiaryArtwork->id, + 'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE, + 'headline' => 'More from this World', + 'body' => Str::limit('Explore more wallpapers, digital art, and creator picks collected inside ' . $world->title . '.', 155, ''), + 'caption' => 'Community picks', + 'alt_text' => (string) ($tertiaryArtwork->title ?: 'World picks'), + 'text_position' => 'bottom', + 'overlay_strength' => 35, + 'animation' => 'pan-right', + 'active' => true, + ]; + } + + while (count($pages) < max(5, $pageCount - 1)) { + $pages[] = [ + 'position' => count($pages) + 1, + 'layout' => WorldWebStoryPage::LAYOUT_MOOD, + 'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE, + 'headline' => 'Inside the theme', + 'body' => Str::limit('A short visual pause that keeps the story connected to ' . $world->title . '.', 150, ''), + 'caption' => 'World mood', + 'alt_text' => 'Mood page for ' . $world->title, + 'text_position' => 'bottom', + 'overlay_strength' => 35, + 'animation' => 'fade-in', + 'active' => true, + ]; + } + + $pages[] = [ + 'position' => count($pages) + 1, + 'layout' => WorldWebStoryPage::LAYOUT_CTA, + 'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE, + 'headline' => 'Explore ' . $world->title, + 'body' => Str::limit('Open the full World page for the complete artwork grid, featured picks, and related creator content.', 160, ''), + 'caption' => 'Continue on Skinbase', + 'cta_label' => 'View World', + 'cta_url' => $world->publicUrl(), + 'alt_text' => 'Explore ' . $world->title . ' on Skinbase', + 'text_position' => 'bottom', + 'overlay_strength' => 45, + 'animation' => 'pulse', + 'active' => true, + ]; + + return collect($pages) + ->take($pageCount) + ->values() + ->map(fn (array $page, int $index): array => array_merge($page, [ + 'position' => $index + 1, + ])) + ->all(); + } + + private function uniqueSlug(string $base, ?int $ignoreId = null): string + { + $candidate = Str::slug($base) ?: 'web-story'; + $slug = $candidate; + $suffix = 2; + + while (WorldWebStory::query()->when($ignoreId, fn ($query) => $query->whereKeyNot($ignoreId))->where('slug', $slug)->exists()) { + $slug = $candidate . '-' . $suffix; + $suffix++; + } + + return $slug; + } +} \ No newline at end of file diff --git a/app/Services/WebStories/WorldWebStorySeoService.php b/app/Services/WebStories/WorldWebStorySeoService.php new file mode 100644 index 00000000..40f736a1 --- /dev/null +++ b/app/Services/WebStories/WorldWebStorySeoService.php @@ -0,0 +1,47 @@ +seo->collectionListing( + 'Skinbase Web Stories', + 'Explore Skinbase Web Stories featuring digital art Worlds, wallpapers, creator highlights, seasonal collections, and visual stories from the Skinbase community.', + route('web-stories.index'), + )->toArray(); + } + + /** + * @return array + */ + public function storyMeta(WorldWebStory $story): array + { + $title = $story->seoTitle(); + $description = $story->seoDescription(); + + return [ + 'title' => $title, + 'description' => $description, + 'canonical' => $story->publicUrl(), + 'robots' => $story->noindex ? 'noindex,follow' : 'index,follow,max-image-preview:large', + 'og_title' => $title, + 'og_description' => $description, + 'og_url' => $story->publicUrl(), + 'og_image' => (string) $story->posterPortraitUrl(), + 'twitter_title' => $title, + 'twitter_description' => $description, + 'twitter_image' => (string) $story->posterPortraitUrl(), + ]; + } +} \ No newline at end of file diff --git a/app/Services/WebStories/WorldWebStoryValidationService.php b/app/Services/WebStories/WorldWebStoryValidationService.php new file mode 100644 index 00000000..0bdc7961 --- /dev/null +++ b/app/Services/WebStories/WorldWebStoryValidationService.php @@ -0,0 +1,174 @@ +, warnings: list, page_count: int} + */ + public function validate(WorldWebStory $story): array + { + $story->loadMissing('orderedPages'); + $pages = $story->orderedPages->where('active', true)->values(); + + $errors = []; + $warnings = []; + + if (trim((string) $story->title) === '') { + $errors[] = 'Story title is required.'; + } + + if (trim((string) $story->slug) === '') { + $errors[] = 'Story slug is required.'; + } + + if (trim((string) $story->poster_portrait_path) === '') { + $errors[] = 'Poster portrait image is required.'; + } + + if (trim((string) $story->publisher_logo_path) === '') { + $errors[] = 'Publisher logo is required.'; + } + + if ($pages->count() < 5) { + $errors[] = 'A published web story must have at least 5 active pages.'; + } + + if ($pages->count() > 10) { + $errors[] = 'A published web story may not have more than 10 active pages.'; + } + + foreach ($pages as $page) { + $pageNumber = (int) $page->position; + $body = trim((string) $page->body); + $headline = trim((string) $page->headline); + + if (in_array((string) $page->background_type, [WorldWebStoryPage::BACKGROUND_IMAGE, WorldWebStoryPage::BACKGROUND_VIDEO], true) + && trim((string) ($page->background_mobile_path ?: $page->background_path)) === '') { + $errors[] = sprintf('Page %d is missing required background media.', $pageNumber); + } + + if (mb_strlen($body) > 180) { + $errors[] = sprintf('Page %d body exceeds 180 characters.', $pageNumber); + } + + if (trim((string) $page->alt_text) === '') { + $errors[] = sprintf('Page %d is missing alt text.', $pageNumber); + } + + if ($headline === '' && $body === '') { + $warnings[] = sprintf('Page %d has no story text.', $pageNumber); + } + + if (filled($page->cta_label) || filled($page->cta_url)) { + if (! filled($page->cta_label) || ! filled($page->cta_url)) { + $errors[] = sprintf('Page %d CTA requires both label and URL.', $pageNumber); + } elseif (! $this->isAllowedCtaUrl((string) $page->cta_url)) { + $errors[] = sprintf('Page %d CTA URL is not allowed.', $pageNumber); + } + } + } + + return [ + 'valid' => $errors === [], + 'errors' => array_values(array_unique($errors)), + 'warnings' => array_values(array_unique($warnings)), + 'page_count' => $pages->count(), + ]; + } + + /** + * @param array $page + */ + public function validatePagePayload(array $page): array + { + $errors = []; + $position = (int) ($page['position'] ?? 0); + $body = trim((string) ($page['body'] ?? '')); + $backgroundType = (string) ($page['background_type'] ?? WorldWebStoryPage::BACKGROUND_IMAGE); + $backgroundPath = trim((string) ($page['background_mobile_path'] ?? $page['background_path'] ?? '')); + $altText = trim((string) ($page['alt_text'] ?? '')); + $ctaUrl = trim((string) ($page['cta_url'] ?? '')); + $ctaLabel = trim((string) ($page['cta_label'] ?? '')); + + if ($body !== '' && mb_strlen($body) > 180) { + $errors['body'] = sprintf('Page %d body exceeds 180 characters.', max(1, $position)); + } + + if (in_array($backgroundType, [WorldWebStoryPage::BACKGROUND_IMAGE, WorldWebStoryPage::BACKGROUND_VIDEO], true) && $backgroundPath === '') { + $errors['background_path'] = 'Background media is required for image and video pages.'; + } + + if ($altText === '') { + $errors['alt_text'] = 'Alt text is required.'; + } + + if (($ctaUrl !== '' || $ctaLabel !== '') && ($ctaUrl === '' || $ctaLabel === '')) { + $errors['cta'] = 'CTA label and URL must both be present.'; + } + + if ($ctaUrl !== '' && ! $this->isAllowedCtaUrl($ctaUrl)) { + $errors['cta_url'] = 'CTA URL must stay on Skinbase or use a relative path.'; + } + + return $errors; + } + + public function assertPublishable(WorldWebStory $story): void + { + $result = $this->validate($story); + + if ($result['valid']) { + return; + } + + throw ValidationException::withMessages([ + 'story' => $result['errors'], + ]); + } + + public function isAllowedCtaUrl(string $url): bool + { + $value = trim($url); + + if ($value === '') { + return false; + } + + if (Str::startsWith($value, ['/'])) { + return true; + } + + $parts = parse_url($value); + $host = strtolower((string) Arr::get($parts, 'host', '')); + + if ($host === '') { + return false; + } + + $allowedHosts = array_filter([ + strtolower((string) parse_url((string) config('app.url'), PHP_URL_HOST)), + 'skinbase.org', + 'www.skinbase.org', + 'skinbase.top', + 'www.skinbase.top', + ]); + + foreach ($allowedHosts as $allowedHost) { + if ($host === $allowedHost || Str::endsWith($host, '.' . $allowedHost)) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/app/Services/Worlds/WorldService.php b/app/Services/Worlds/WorldService.php index 2a71541d..550cfd45 100644 --- a/app/Services/Worlds/WorldService.php +++ b/app/Services/Worlds/WorldService.php @@ -17,6 +17,7 @@ use App\Models\User; use App\Models\World; use App\Models\WorldRelation; use App\Models\WorldSubmission; +use App\Models\WorldWebStory; use App\Services\CollectionService; use App\Services\GroupCardService; use App\Services\Maturity\ArtworkMaturityService; @@ -591,7 +592,7 @@ final class WorldService public function publicShowPayload(World $world, ?User $viewer = null, bool $includeDraftRecap = false): array { - $world->loadMissing(['createdBy.profile', 'parentWorld', 'worldRelations', 'linkedChallenge.group', 'recapArticle.author.profile', 'recapArticle.category']); + $world->loadMissing(['createdBy.profile', 'parentWorld', 'worldRelations', 'linkedChallenge.group', 'recapArticle.author.profile', 'recapArticle.category', 'publishedWebStory']); $sections = $this->resolveSections($world, $viewer); $familyEditions = $this->familyEditionsForWorld($world); @@ -673,6 +674,7 @@ final class WorldService 'archiveEditions' => $archiveEditions, 'familySummary' => $this->mapRecurringFamilySummary($world), 'relatedWorlds' => $relatedWorlds, + 'webStory' => $this->publishedWebStoryPayload($world), ]; } @@ -1406,7 +1408,7 @@ final class WorldService private function mapWorldDetail(World $world): array { - $world->loadMissing(['linkedChallenge.group', 'worldRelations', 'recapArticle.author.profile', 'recapArticle.category']); + $world->loadMissing(['linkedChallenge.group', 'worldRelations', 'recapArticle.author.profile', 'recapArticle.category', 'publishedWebStory']); $theme = $this->themePayload($world); $familyTitle = $this->recurrenceFamilyLabel($world); $familyUrl = $this->familyUrlForWorld($world); @@ -1477,6 +1479,26 @@ final class WorldService 'rewarded_contributor_count' => (int) $world->worldRewardGrants()->count(), 'relation_count' => (int) ($world->world_relations_count ?? $world->worldRelations()->count()), 'public_url' => $this->publicUrlForWorld($world), + 'published_web_story' => $this->publishedWebStoryPayload($world), + ]; + } + + private function publishedWebStoryPayload(World $world): ?array + { + $story = $world->publishedWebStory; + + if (! $story instanceof WorldWebStory) { + return null; + } + + return [ + 'id' => (int) $story->id, + 'slug' => (string) $story->slug, + 'title' => (string) $story->title, + 'excerpt' => (string) ($story->excerpt ?? ''), + 'poster_portrait_url' => $story->posterPortraitUrl(), + 'url' => $story->publicUrl(), + 'published_at' => optional($story->published_at)?->toIso8601String(), ]; } diff --git a/app/Support/AcademyAnalytics/AcademyAnalyticsContentType.php b/app/Support/AcademyAnalytics/AcademyAnalyticsContentType.php new file mode 100644 index 00000000..f61a91b6 --- /dev/null +++ b/app/Support/AcademyAnalytics/AcademyAnalyticsContentType.php @@ -0,0 +1,45 @@ + + */ + public static function values(): array + { + return [ + self::HOME, + self::PROMPT, + self::LESSON, + self::COURSE, + self::PROMPT_PACK, + self::CHALLENGE, + self::SEARCH, + self::UPGRADE, + ]; + } + + public static function requiresContentId(string $contentType): bool + { + return in_array($contentType, [ + self::PROMPT, + self::LESSON, + self::COURSE, + self::PROMPT_PACK, + self::CHALLENGE, + ], true); + } +} \ No newline at end of file diff --git a/app/Support/AcademyAnalytics/AcademyAnalyticsEventType.php b/app/Support/AcademyAnalytics/AcademyAnalyticsEventType.php new file mode 100644 index 00000000..b3764d76 --- /dev/null +++ b/app/Support/AcademyAnalytics/AcademyAnalyticsEventType.php @@ -0,0 +1,72 @@ + + */ + public static function values(): array + { + return [ + self::PAGE_VIEW, + self::CONTENT_VIEW, + self::ENGAGED_VIEW, + self::SCROLL_50, + self::SCROLL_75, + self::SCROLL_100, + self::PROMPT_COPY, + self::PROMPT_NEGATIVE_COPY, + self::PROMPT_LIKE, + self::PROMPT_SAVE, + self::PROMPT_PACK_VIEW, + self::PROMPT_PACK_DOWNLOAD, + self::LESSON_VIEW, + self::LESSON_STARTED, + self::LESSON_COMPLETED, + self::COURSE_VIEW, + self::COURSE_STARTED, + self::COURSE_COMPLETED, + self::CHALLENGE_VIEW, + self::CHALLENGE_STARTED, + self::CHALLENGE_SUBMITTED, + self::SEARCH, + self::ZERO_SEARCH_RESULTS, + self::SEARCH_RESULT_CLICK, + self::PREMIUM_PREVIEW_VIEW, + self::UPGRADE_CLICK, + self::OUTBOUND_CLICK, + ]; + } +} \ No newline at end of file diff --git a/app/Support/AcademyAnalytics/AcademyAnalyticsProgressStatus.php b/app/Support/AcademyAnalytics/AcademyAnalyticsProgressStatus.php new file mode 100644 index 00000000..e9945ce5 --- /dev/null +++ b/app/Support/AcademyAnalytics/AcademyAnalyticsProgressStatus.php @@ -0,0 +1,26 @@ + + */ + public static function values(): array + { + return [ + self::NOT_STARTED, + self::STARTED, + self::IN_PROGRESS, + self::COMPLETED, + ]; + } +} \ No newline at end of file diff --git a/app/Support/Seo/SeoFactory.php b/app/Support/Seo/SeoFactory.php index 8e0f2ffa..354a6f25 100644 --- a/app/Support/Seo/SeoFactory.php +++ b/app/Support/Seo/SeoFactory.php @@ -52,9 +52,11 @@ final class SeoFactory $description = Str::limit($description !== '' ? $description : $title, 160, '…'); $image = $thumbs['xl']['url'] ?? $thumbs['lg']['url'] ?? $thumbs['md']['url'] ?? null; $keywords = $artwork->tags->pluck('name')->filter()->unique()->values()->all(); - $licenseUrl = $this->clean((string) ($artwork->license_url ?? '')); $publisherName = (string) config('seo.site_name', 'Skinbase'); $publisherUrl = url('/'); + $licensePageUrl = route('terms-of-service'); + $licenseUrl = $this->clean((string) ($artwork->license_url ?? '')); + $licenseUrl = $licenseUrl !== null ? $licenseUrl : $licensePageUrl; $imageWidth = $thumbs['xl']['width'] ?? $thumbs['lg']['width'] ?? null; $imageHeight = $thumbs['xl']['height'] ?? $thumbs['lg']['height'] ?? null; @@ -83,6 +85,8 @@ final class SeoFactory 'creditText' => $authorName, 'datePublished' => optional($artwork->published_at)->toAtomString(), 'license' => $licenseUrl, + 'acquireLicensePage' => $licensePageUrl, + 'copyrightNotice' => $authorName, 'keywords' => $keywords !== [] ? $keywords : null, 'representativeOfPage' => true, ], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== [])) diff --git a/bootstrap/app.php b/bootstrap/app.php index 457d2273..a4318553 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -32,6 +32,7 @@ return Application::configure(basePath: dirname(__DIR__)) 'chat_post', 'chat_post/*', 'api/art/*/view', + 'stripe/*', ]); $middleware->web(append: [ diff --git a/bootstrap/ssr/ssr-manifest.json b/bootstrap/ssr/ssr-manifest.json index 36cfea31..4a07b65a 100644 --- a/bootstrap/ssr/ssr-manifest.json +++ b/bootstrap/ssr/ssr-manifest.json @@ -2038,13 +2038,23 @@ "resources/js/Layouts/AdminLayout.jsx": [], "resources/js/Layouts/SettingsLayout.jsx": [], "resources/js/Layouts/StudioLayout.jsx": [], + "resources/js/Pages/Academy/Billing/Account.jsx": [], + "resources/js/Pages/Academy/Billing/Cancel.jsx": [], + "resources/js/Pages/Academy/Billing/Pricing.jsx": [], + "resources/js/Pages/Academy/Billing/Success.jsx": [], "resources/js/Pages/Academy/ChallengeSubmit.jsx": [], "resources/js/Pages/Academy/CoursesIndex.jsx": [], "resources/js/Pages/Academy/CoursesShow.jsx": [], "resources/js/Pages/Academy/Index.jsx": [], "resources/js/Pages/Academy/List.jsx": [], - "resources/js/Pages/Academy/Pricing.jsx": [], "resources/js/Pages/Academy/Show.jsx": [], + "resources/js/Pages/Admin/Academy/AnalyticsContent.jsx": [], + "resources/js/Pages/Admin/Academy/AnalyticsFunnel.jsx": [], + "resources/js/Pages/Admin/Academy/AnalyticsIntelligence.jsx": [], + "resources/js/Pages/Admin/Academy/AnalyticsNav.jsx": [], + "resources/js/Pages/Admin/Academy/AnalyticsOverview.jsx": [], + "resources/js/Pages/Admin/Academy/AnalyticsSearch.jsx": [], + "resources/js/Pages/Admin/Academy/Billing.jsx": [], "resources/js/Pages/Admin/Academy/CourseBuilder.jsx": [], "resources/js/Pages/Admin/Academy/CourseEditor.jsx": [], "resources/js/Pages/Admin/Academy/CrudForm.jsx": [], @@ -2132,6 +2142,8 @@ "resources/js/Pages/Messages/Index.jsx": [], "resources/js/Pages/Moderation/AiBiographyAdmin.jsx": [], "resources/js/Pages/Moderation/ArtworkMaturityQueue.jsx": [], + "resources/js/Pages/Moderation/WorldWebStoriesIndex.jsx": [], + "resources/js/Pages/Moderation/WorldWebStoryEditor.jsx": [], "resources/js/Pages/News/NewsComments.jsx": [], "resources/js/Pages/News/NewsImagePreview.jsx": [], "resources/js/Pages/Profile/ProfileGallery.jsx": [], @@ -2210,6 +2222,8 @@ "resources/js/components/Feed/VisibilityPill.jsx": [], "resources/js/components/Studio/ConfirmDangerModal.jsx": [], "resources/js/components/Studio/StudioContentBrowser.jsx": [], + "resources/js/components/academy/billing/AccessBadge.jsx": [], + "resources/js/components/academy/billing/PlanCard.jsx": [], "resources/js/components/achievements/AchievementBadge.jsx": [], "resources/js/components/achievements/AchievementCard.jsx": [], "resources/js/components/achievements/AchievementsList.jsx": [], @@ -2434,6 +2448,7 @@ "resources/js/hooks/upload/useUploadMachine.js": [], "resources/js/hooks/upload/useVisionTags.js": [], "resources/js/hooks/useWebShare.js": [], + "resources/js/lib/academyAnalytics.js": [], "resources/js/lib/security/botFingerprint.js": [], "resources/js/lib/uploadAnalytics.js": [], "resources/js/lib/uploadEndpoints.js": [], diff --git a/bootstrap/ssr/ssr.js b/bootstrap/ssr/ssr.js index 38974871..07c95248 100644 --- a/bootstrap/ssr/ssr.js +++ b/bootstrap/ssr/ssr.js @@ -1,4 +1,4 @@ -import { g as getDefaultExportFromCjs, c as commonjsGlobal, r as reactExports, R as React, a as reactDomExports, b as ReactRenderer, i as index_default, d as ReactNodeViewRenderer, m as mergeAttributes, N as NodeViewWrapper, e as Node3, B as BubbleMenu, f as index_default$1, T as TableRow, h as index_default$2, k as index_default$3, l as Table, n as TableHeader, o as TableCell, p as index_default$4, q as index_default$5, u as useEditor, E as EditorContent, j as jsxRuntimeExports, s as requireReact, t as requireReactDom } from "./assets/vendor-tiptap-DRFaxGEb.js"; +import { g as getDefaultExportFromCjs, c as commonjsGlobal, r as reactExports, R as React, a as reactDomExports, b as ReactRenderer, i as index_default, d as ReactNodeViewRenderer, m as mergeAttributes, N as NodeViewWrapper, e as Node3, B as BubbleMenu, f as index_default$1, T as TableRow, h as index_default$2, k as index_default$3, l as Table$1, n as TableHeader, o as TableCell, p as index_default$4, q as index_default$5, u as useEditor, E as EditorContent, j as jsxRuntimeExports, s as requireReact, t as requireReactDom } from "./assets/vendor-tiptap-DRFaxGEb.js"; import require$$0$1 from "util"; import stream, { Readable } from "stream"; import require$$1 from "path"; @@ -12360,6 +12360,91 @@ function X$1() { return l3; } var At = Ne$1; +const LABELS$1 = { + free: "Free", + creator: "Creator", + pro: "Pro", + admin: "Admin" +}; +const CLASSES = { + free: "border-white/12 bg-white/[0.06] text-slate-200", + creator: "border-amber-300/25 bg-amber-300/12 text-amber-100", + pro: "border-sky-300/25 bg-sky-300/12 text-sky-100", + admin: "border-emerald-300/25 bg-emerald-300/12 text-emerald-100" +}; +function AccessBadge({ tier = "free", className = "" }) { + const normalizedTier = typeof tier === "string" ? tier.toLowerCase() : "free"; + const label = LABELS$1[normalizedTier] || "Free"; + const tone = CLASSES[normalizedTier] || CLASSES.free; + return /* @__PURE__ */ React.createElement("span", { className: `inline-flex items-center rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] ${tone} ${className}`.trim() }, label); +} +function formatDate$f(iso) { + if (!iso) return null; + try { + return new Date(iso).toLocaleDateString(void 0, { year: "numeric", month: "long", day: "numeric" }); + } catch { + return null; + } +} +function AcademyBillingAccount({ currentTier, isSubscribed, subscription, activePlan = null, links = {} }) { + const endsAt = formatDate$f(subscription?.endsAt); + const onGracePeriod = subscription?.onGracePeriod === true; + const subscriptionActive = subscription?.active === true; + return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.14),_transparent_26%),linear-gradient(180deg,_#07111f_0%,_#0f172a_45%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(Se$1, { title: "Academy Subscription" }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-[1280px] space-y-8" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(7,17,31,0.95),rgba(12,24,45,0.9),rgba(15,23,42,0.96))] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.42)] md:p-10" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-100/85" }, "Skinbase Academy"), /* @__PURE__ */ React.createElement(AccessBadge, { tier: currentTier })), /* @__PURE__ */ React.createElement("h1", { className: "mt-4 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl" }, isSubscribed ? "Your subscription" : "Academy subscription"), /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-2xl text-base leading-8 text-slate-300" }, isSubscribed ? "Your Academy access is active. Manage, upgrade, or cancel your subscription here at any time." : "You are on the free Academy tier. Upgrade to Creator or Pro to unlock premium content.")), onGracePeriod && endsAt ? /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-amber-300/25 bg-amber-300/[0.06] px-6 py-5" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-amber-100" }, "Your subscription was cancelled and will end on ", endsAt, "."), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-amber-100/75" }, "You still have full access until that date. Open the subscription portal to resume your plan if you change your mind."), /* @__PURE__ */ React.createElement( + xe, + { + href: links.portal, + className: "mt-4 inline-flex items-center rounded-full border border-amber-300/30 bg-amber-300/12 px-5 py-2.5 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/20" + }, + "Resume subscription" + )) : null, !isSubscribed ? /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-[linear-gradient(135deg,rgba(8,47,73,0.92),rgba(30,41,59,0.94))] p-6 md:p-7" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100/85" }, "Upgrade"), /* @__PURE__ */ React.createElement("h2", { className: "mt-3 text-2xl font-semibold tracking-[-0.04em] text-white" }, "Choose a plan to get started"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 max-w-xl text-sm leading-7 text-slate-200/90" }, "Creator unlocks premium lessons and the full prompt library for €4.99/month. Pro gives you everything — all lessons, the advanced content track, and every new Academy drop — for €9.99/month."), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement( + xe, + { + href: links.pricing || "/academy/pricing", + className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18" + }, + "See plans and pricing" + ), /* @__PURE__ */ React.createElement( + xe, + { + href: links.academy || "/academy", + className: "rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]" + }, + "Back to Academy" + ))) : null, isSubscribed ? /* @__PURE__ */ React.createElement("section", { className: "grid gap-5 xl:grid-cols-[minmax(0,1fr)_320px]" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-5 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 md:p-7" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Subscription details"), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 sm:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Active plan"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-lg font-semibold text-white" }, activePlan?.label || "Academy plan"), activePlan?.price_display ? /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, activePlan.price_display, " / month") : null), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Status"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-lg font-semibold capitalize text-white" }, onGracePeriod ? "Cancelling" : subscriptionActive ? "Active" : subscription?.status || "Active"), onGracePeriod && endsAt ? /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-amber-300/80" }, "Access ends ", endsAt) : null, !onGracePeriod && subscriptionActive ? /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-emerald-300/80" }, "Renews automatically") : null)), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Your Academy access"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex flex-wrap items-center gap-3" }, /* @__PURE__ */ React.createElement(AccessBadge, { tier: currentTier }), /* @__PURE__ */ React.createElement("p", { className: "text-sm text-slate-300" }, currentTier === "pro" ? "Full access to all Academy lessons and content." : currentTier === "creator" ? "Full access to all Creator lessons and prompts." : "Access to free Academy content.")))), /* @__PURE__ */ React.createElement("aside", { className: "space-y-3 rounded-[32px] border border-white/10 bg-black/20 p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300" }, "Manage"), /* @__PURE__ */ React.createElement("p", { className: "text-xs leading-6 text-slate-400" }, "Use the subscription portal to upgrade, downgrade, or cancel. Changes take effect at your next billing date."), /* @__PURE__ */ React.createElement( + xe, + { + href: links.portal, + className: "mt-2 inline-flex w-full items-center justify-center rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18" + }, + "Upgrade, downgrade or cancel" + ), /* @__PURE__ */ React.createElement( + xe, + { + href: links.pricing || "/academy/pricing", + className: "inline-flex w-full items-center justify-center rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]" + }, + "Compare plans" + ), /* @__PURE__ */ React.createElement( + xe, + { + href: links.academy || "/academy", + className: "inline-flex w-full items-center justify-center rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]" + }, + "Go to Academy" + ))) : null)); +} +const __vite_glob_0_0 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ + __proto__: null, + default: AcademyBillingAccount +}, Symbol.toStringTag, { value: "Module" })); +function AcademyBillingCancel({ message, links = {} }) { + return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.14),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(148,163,184,0.14),_transparent_26%),linear-gradient(180deg,_#07111f_0%,_#0f172a_45%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(Se$1, { title: "Academy Billing Canceled" }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-[920px] space-y-8" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(7,17,31,0.95),rgba(12,24,45,0.9),rgba(67,20,7,0.78))] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.42)] md:p-10" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-amber-100/85" }, "Checkout canceled"), /* @__PURE__ */ React.createElement("h1", { className: "mt-4 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl" }, "No payment was made."), /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-2xl text-base leading-8 text-slate-300" }, message)), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement(xe, { href: links.pricing || "/academy/pricing", className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18" }, "Return to pricing"), /* @__PURE__ */ React.createElement(xe, { href: links.academy || "/academy", className: "rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]" }, "Back to Academy")))); +} +const __vite_glob_0_1 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ + __proto__: null, + default: AcademyBillingCancel +}, Symbol.toStringTag, { value: "Module" })); function normalizeJsonLd(input) { if (!input) return []; return (Array.isArray(input) ? input : [input]).filter((schema) => schema && typeof schema === "object"); @@ -12395,6 +12480,408 @@ function SeoHead({ seo = {}, title = null, description = null, jsonLd = null }) ); })); } +function ActionButton$1({ disabled, children, onClick, href, tone = "primary" }) { + const toneClass = { + primary: "border-sky-300/25 bg-sky-300/12 text-sky-100 hover:border-sky-300/40 hover:bg-sky-300/18", + emerald: "border-emerald-300/25 bg-emerald-300/10 text-emerald-100 hover:bg-emerald-300/18", + default: "border-white/10 bg-white/[0.05] text-white hover:border-white/20 hover:bg-white/[0.08]" + }[tone] ?? "border-white/10 bg-white/[0.05] text-white hover:border-white/20 hover:bg-white/[0.08]"; + if (href) { + return /* @__PURE__ */ React.createElement(xe, { href, className: `inline-flex w-full items-center justify-center rounded-full border px-5 py-3 text-sm font-semibold transition ${toneClass}` }, children); + } + return /* @__PURE__ */ React.createElement("button", { type: "button", disabled, onClick, className: `inline-flex w-full items-center justify-center rounded-full border px-5 py-3 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-60 ${toneClass}` }, children); +} +function PlanCard({ product, selectedPlan, currentTier, isSubscribed, activePlanKey, billingEnabled, loginHref, manageHref, onCheckout }) { + const activeTier = typeof currentTier === "string" ? currentTier.toLowerCase() : "free"; + const isActivePlan = selectedPlan?.key === activePlanKey; + const isHigherTierCovered = activeTier === "pro" && product.tier === "creator"; + const isPlanReady = Boolean(selectedPlan?.configured && selectedPlan?.price_id_valid); + const isSubscribedElsewhere = isSubscribed && !isActivePlan; + return /* @__PURE__ */ React.createElement("article", { className: `relative overflow-hidden rounded-[32px] border p-6 transition md:p-7 ${isActivePlan ? "border-emerald-300/25 bg-[linear-gradient(180deg,rgba(16,185,129,0.1),rgba(15,23,42,0.96))] shadow-[0_28px_90px_rgba(5,150,105,0.14)]" : product.featured ? "border-sky-300/25 bg-[linear-gradient(180deg,rgba(14,165,233,0.12),rgba(15,23,42,0.96))] shadow-[0_28px_90px_rgba(2,132,199,0.14)]" : "border-white/10 bg-white/[0.04]"}` }, /* @__PURE__ */ React.createElement("div", { className: "absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.45),transparent)]" }), /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, product.badge), /* @__PURE__ */ React.createElement("h2", { className: "mt-3 text-3xl font-semibold tracking-[-0.05em] text-white" }, product.name), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, product.description)), /* @__PURE__ */ React.createElement("div", { className: "flex shrink-0 flex-col items-end gap-2" }, isActivePlan ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-emerald-300/30 bg-emerald-300/14 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-100" }, "Your plan") : /* @__PURE__ */ React.createElement(AccessBadge, { tier: product.tier }))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-400" }, "Monthly"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-3xl font-semibold tracking-[-0.04em] text-white" }, selectedPlan?.price_display || "—"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-xs uppercase tracking-[0.18em] text-slate-500" }, "Billed monthly · cancel anytime")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 space-y-3 text-sm text-slate-300" }, product.features.map((feature) => /* @__PURE__ */ React.createElement("div", { key: feature, className: "flex items-start gap-2.5 rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("span", { className: "mt-px shrink-0 text-emerald-400" }, "✓"), /* @__PURE__ */ React.createElement("span", null, feature)))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 space-y-3" }, isActivePlan ? /* @__PURE__ */ React.createElement(ActionButton$1, { href: manageHref, tone: "emerald" }, "Manage subscription") : null, isSubscribedElsewhere && !isHigherTierCovered ? /* @__PURE__ */ React.createElement(ActionButton$1, { href: manageHref, tone: "default" }, "Switch to ", product.name) : null, isHigherTierCovered && !isActivePlan ? /* @__PURE__ */ React.createElement("p", { className: "text-center text-xs text-slate-500" }, "Included in your Pro plan") : null, !isSubscribed && loginHref ? /* @__PURE__ */ React.createElement(ActionButton$1, { href: loginHref, tone: "primary" }, billingEnabled ? `Get ${product.name}` : "Coming soon") : null, !isSubscribed && !loginHref ? /* @__PURE__ */ React.createElement( + ActionButton$1, + { + disabled: !billingEnabled || !isPlanReady, + onClick: () => onCheckout(selectedPlan), + tone: "primary" + }, + !billingEnabled ? "Coming soon" : isPlanReady ? `Get ${product.name} — ${selectedPlan?.price_display || ""}` : "Not available yet" + ) : null)); +} +const VISITOR_STORAGE_KEY$1 = "academy.analytics.visitor-id"; +const VISITOR_COOKIE_NAME = "academy_visitor_id"; +const ONCE_PREFIX = "academy.analytics.once:"; +function getCsrfToken$k() { + if (typeof document === "undefined") { + return ""; + } + return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; +} +function getCookieValue(name2) { + if (typeof document === "undefined") { + return ""; + } + const match = document.cookie.match(new RegExp(`(?:^|; )${name2}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : ""; +} +function generateVisitorId() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `academy-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} +function ensureVisitorId() { + if (typeof window === "undefined") { + return null; + } + let visitorId = ""; + try { + visitorId = window.localStorage.getItem(VISITOR_STORAGE_KEY$1) || ""; + } catch { + visitorId = ""; + } + if (!visitorId) { + visitorId = getCookieValue(VISITOR_COOKIE_NAME); + } + if (!visitorId) { + visitorId = generateVisitorId(); + } + try { + window.localStorage.setItem(VISITOR_STORAGE_KEY$1, visitorId); + } catch { + } + if (typeof document !== "undefined") { + document.cookie = `${VISITOR_COOKIE_NAME}=${encodeURIComponent(visitorId)}; path=/; max-age=31536000; SameSite=Lax`; + } + return visitorId; +} +function buildPayload$1(payload = {}) { + return { + ...payload, + visitor_id: payload.visitor_id || ensureVisitorId(), + url: payload.url || (typeof window !== "undefined" ? window.location.href : null), + _token: payload._token || getCsrfToken$k() + }; +} +function markOnce(onceKey) { + if (!onceKey || typeof window === "undefined") { + return false; + } + const storageKey2 = `${ONCE_PREFIX}${onceKey}`; + try { + if (window.sessionStorage.getItem(storageKey2)) { + return true; + } + window.sessionStorage.setItem(storageKey2, "1"); + } catch { + return false; + } + return false; +} +async function postAcademyAction(url, payload = {}) { + if (!url || typeof window === "undefined") { + return null; + } + const response = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + "X-CSRF-TOKEN": getCsrfToken$k() + }, + credentials: "same-origin", + body: JSON.stringify(buildPayload$1(payload)) + }).catch(() => null); + if (!response?.ok) { + return null; + } + const responseContentType = response.headers.get("content-type") || ""; + if (!responseContentType.includes("application/json")) { + return null; + } + return response.json().catch(() => null); +} +function trackAcademyEvent(eventType, contentType, contentId, metadata = {}, options = {}) { + if (!eventType || !options?.url || typeof window === "undefined") { + return Promise.resolve(false); + } + if (options.onceKey && markOnce(options.onceKey)) { + return Promise.resolve(false); + } + const payload = buildPayload$1({ + event_type: eventType, + content_type: contentType || null, + content_id: contentId || null, + metadata, + route_name: options.pageName || null + }); + const body2 = JSON.stringify(payload); + if (options.useBeacon !== false && typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") { + try { + const blob = new Blob([body2], { type: "application/json" }); + const queued = navigator.sendBeacon(options.url, blob); + if (queued) { + return Promise.resolve(true); + } + } catch { + } + } + return fetch(options.url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + "X-CSRF-TOKEN": getCsrfToken$k() + }, + credentials: "same-origin", + keepalive: options.keepalive === true, + body: body2 + }).then(() => true).catch(() => false); +} +function normalizeAcademySearchQuery(query = "") { + const normalizedWhitespace = String(query).trim().toLowerCase().replace(/\s+/g, " "); + return normalizedWhitespace.replace(/[^a-z0-9\s\-_]+/g, "").trim(); +} +function trackAcademySearchResultClick(analytics, search2, result) { + if (!analytics?.eventUrl || !search2?.query || !result?.contentType || !result?.contentId) { + return; + } + void trackAcademyEvent("academy_search_result_click", result.contentType, result.contentId, { + query: search2.query, + normalized_query: search2.normalizedQuery || normalizeAcademySearchQuery(search2.query), + results_count: Number(search2.resultsCount || 0), + position: result.position || null, + source: result.source || "academy_search_results", + filters: search2.filters || {} + }, { + url: analytics.eventUrl, + pageName: analytics.pageName, + keepalive: true + }); +} +function contentViewEventType(contentType) { + if (contentType === "academy_lesson") return "academy_lesson_view"; + if (contentType === "academy_course") return "academy_course_view"; + if (contentType === "academy_prompt_pack") return "academy_prompt_pack_view"; + if (contentType === "academy_challenge") return "academy_challenge_view"; + return "academy_content_view"; +} +function trackUpgradeClick(analytics, metadata = {}) { + if (!analytics?.eventUrl) { + return; + } + void trackAcademyEvent("academy_upgrade_click", analytics?.contentType || "academy_upgrade", analytics?.contentId || null, metadata, { + url: analytics.eventUrl, + pageName: analytics.pageName, + useBeacon: false + }); +} +function useAcademyPageAnalytics(analytics) { + reactExports.useEffect(() => { + if (!analytics?.enabled || !analytics?.eventUrl || typeof window === "undefined") { + return void 0; + } + const baseKey = `${analytics.pageName || window.location.pathname}:${analytics.contentType || "page"}:${analytics.contentId || "none"}`; + void trackAcademyEvent("academy_page_view", analytics.contentType || null, analytics.contentId || null, { + page_name: analytics.pageName + }, { + url: analytics.eventUrl, + pageName: analytics.pageName, + onceKey: `${baseKey}:page-view` + }); + if (analytics.contentType || analytics.contentId) { + void trackAcademyEvent(contentViewEventType(analytics.contentType), analytics.contentType || null, analytics.contentId || null, { + page_name: analytics.pageName + }, { + url: analytics.eventUrl, + pageName: analytics.pageName, + onceKey: `${baseKey}:content-view` + }); + } + if (analytics.isPremium && analytics.isLocked) { + void trackAcademyEvent("academy_premium_preview_view", analytics.contentType || null, analytics.contentId || null, { + page_name: analytics.pageName + }, { + url: analytics.eventUrl, + pageName: analytics.pageName, + onceKey: `${baseKey}:premium-preview` + }); + } + const engagedTimer = window.setTimeout(() => { + void trackAcademyEvent("academy_engaged_view", analytics.contentType || null, analytics.contentId || null, { + page_name: analytics.pageName, + engaged_seconds: 15 + }, { + url: analytics.eventUrl, + pageName: analytics.pageName, + onceKey: `${baseKey}:engaged` + }); + }, 15e3); + const sentMilestones = /* @__PURE__ */ new Set(); + const onScroll = () => { + const doc = document.documentElement; + const scrollable = Math.max(1, doc.scrollHeight - window.innerHeight); + const percent = Math.min(100, Math.round(window.scrollY / scrollable * 100)); + [ + { threshold: 50, eventType: "academy_scroll_50" }, + { threshold: 75, eventType: "academy_scroll_75" }, + { threshold: 100, eventType: "academy_scroll_100" } + ].forEach((milestone) => { + if (percent < milestone.threshold || sentMilestones.has(milestone.threshold)) { + return; + } + sentMilestones.add(milestone.threshold); + void trackAcademyEvent(milestone.eventType, analytics.contentType || null, analytics.contentId || null, { + page_name: analytics.pageName, + scroll_percent: milestone.threshold + }, { + url: analytics.eventUrl, + pageName: analytics.pageName, + onceKey: `${baseKey}:scroll-${milestone.threshold}` + }); + }); + }; + window.addEventListener("scroll", onScroll, { passive: true }); + return () => { + window.clearTimeout(engagedTimer); + window.removeEventListener("scroll", onScroll); + }; + }, [analytics?.contentId, analytics?.contentType, analytics?.enabled, analytics?.eventUrl, analytics?.isLocked, analytics?.isPremium, analytics?.pageName]); +} +function getCsrfToken$j() { + return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; +} +function heroText(currentTier, isSubscribed) { + if (isSubscribed && currentTier === "pro") { + return { + heading: "You have full Academy access.", + body: "All lessons, prompts, and Academy content are unlocked on your Pro plan. To upgrade, downgrade, or cancel, use the subscription manager below." + }; + } + if (isSubscribed && currentTier === "creator") { + return { + heading: "You're on the Creator plan.", + body: "Creator content is fully unlocked. Upgrade to Pro anytime to access the advanced lesson track and everything new that launches at the Pro tier." + }; + } + if (currentTier === "admin") { + return { + heading: "Academy plans.", + body: "Your admin account already has full Academy access. Browse the plans below." + }; + } + if (isSubscribed) { + return { + heading: "Manage your Academy subscription.", + body: "Your plan is active. Review your options below or use the subscription manager to make changes." + }; + } + return { + heading: "Unlock everything in Academy.", + body: "Start free and upgrade when you're ready. Creator unlocks premium lessons and the full prompt library. Pro adds the advanced lesson track and is the highest Academy tier." + }; +} +function SidePanel({ currentTier, isSubscribed, activePlanLabel, activePlanPrice, manageHref }) { + if (isSubscribed) { + return /* @__PURE__ */ React.createElement("div", { className: "rounded-[30px] border border-emerald-300/20 bg-emerald-300/[0.06] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200/80" }, "Your subscription"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] uppercase tracking-[0.16em] text-slate-500" }, "Active plan"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm font-semibold text-white" }, activePlanLabel || "Academy plan")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] uppercase tracking-[0.16em] text-slate-500" }, "Billed monthly"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm font-semibold text-white" }, activePlanPrice || "—"))), manageHref ? /* @__PURE__ */ React.createElement(xe, { href: manageHref, className: "mt-5 inline-flex w-full items-center justify-center rounded-full border border-emerald-300/30 bg-emerald-300/10 px-5 py-3 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-300/18" }, "Manage subscription") : null); + } + return /* @__PURE__ */ React.createElement("div", { className: "rounded-[30px] border border-white/10 bg-black/20 p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Why upgrade?"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, [ + { title: "Instant access", body: "Subscription activates the moment payment is confirmed." }, + { title: "Cancel anytime", body: "No lock-in. Keep access until the end of the billing period." }, + { title: "Switch freely", body: "Move between Creator and Pro from your subscription manager." } + ].map(({ title, body: body2 }) => /* @__PURE__ */ React.createElement("div", { key: title, className: "rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-white" }, title), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-xs leading-5 text-slate-400" }, body2))))); +} +function AcademyBillingPricing({ seo, billingEnabled, currentTier, isSubscribed, activePlanKey = null, activePlanLabel = null, catalog = [], links = {}, analytics }) { + const { auth, errors, flash } = X$1().props; + useAcademyPageAnalytics(analytics); + const loginHref = auth?.user ? null : `${links.login || "/login"}?intended=${encodeURIComponent(links.pricing || "/academy/pricing")}`; + const products = catalog.map((product) => ({ + ...product, + selectedPlan: product.plans[0] || null + })); + const activePlanPrice = products.flatMap((p) => p.plans).find((p) => p?.key === activePlanKey)?.price_display || null; + const handleCheckout = (plan) => { + if (!plan?.key || !links.checkout) return; + trackUpgradeClick(analytics, { + source: "academy_billing_pricing", + academy_plan: plan.key, + academy_interval: plan.interval + }); + const form = document.createElement("form"); + form.method = "POST"; + form.action = links.checkout; + form.style.display = "none"; + const csrfInput = document.createElement("input"); + csrfInput.type = "hidden"; + csrfInput.name = "_token"; + csrfInput.value = getCsrfToken$j(); + const planInput = document.createElement("input"); + planInput.type = "hidden"; + planInput.name = "plan"; + planInput.value = plan.key; + form.appendChild(csrfInput); + form.appendChild(planInput); + document.body.appendChild(form); + form.submit(); + }; + const hero = heroText(currentTier, isSubscribed); + const showFreeBadgeAsCurrentPlan = currentTier === "free" && !isSubscribed && auth?.user; + return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.16),_transparent_22%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.18),_transparent_26%),linear-gradient(180deg,_#07111f_0%,_#0f172a_45%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { seo: seo || {}, title: "Skinbase Academy — Plans & Pricing", description: seo?.description }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-[1380px] space-y-8" }, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(7,17,31,0.95),rgba(12,24,45,0.9),rgba(67,20,7,0.82))] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.42)] md:p-10 lg:p-12" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-8 xl:grid-cols-[minmax(0,1fr)_320px] xl:items-start" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-amber-200/85" }, "Skinbase Academy"), currentTier !== "free" ? /* @__PURE__ */ React.createElement(AccessBadge, { tier: currentTier }) : null), /* @__PURE__ */ React.createElement("h1", { className: "mt-4 max-w-3xl text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl xl:text-6xl" }, hero.heading), /* @__PURE__ */ React.createElement("p", { className: "mt-5 max-w-2xl text-base leading-8 text-slate-300 md:text-lg" }, hero.body), errors?.plan ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm font-medium text-rose-200" }, errors.plan) : null, flash?.error ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm font-medium text-rose-100" }, flash.error) : null, flash?.success ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm font-medium text-emerald-100" }, flash.success) : null), /* @__PURE__ */ React.createElement( + SidePanel, + { + currentTier, + isSubscribed, + activePlanLabel, + activePlanPrice, + manageHref: links.billingAccount + } + ))), /* @__PURE__ */ React.createElement("section", { className: "grid gap-5 xl:grid-cols-[minmax(0,0.9fr)_1fr_1fr]" }, /* @__PURE__ */ React.createElement("article", { className: `rounded-[32px] border p-6 md:p-7 ${showFreeBadgeAsCurrentPlan ? "border-white/20 bg-white/[0.06]" : "border-white/10 bg-white/[0.04]"}` }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Free"), /* @__PURE__ */ React.createElement("h2", { className: "mt-3 text-3xl font-semibold tracking-[-0.05em] text-white" }, "Explorer")), showFreeBadgeAsCurrentPlan ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/20 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200" }, "Your plan") : /* @__PURE__ */ React.createElement(AccessBadge, { tier: "free" })), /* @__PURE__ */ React.createElement("div", { className: "mt-6 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-3xl font-semibold tracking-[-0.04em] text-white" }, "Free"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-xs uppercase tracking-[0.18em] text-slate-500" }, "No payment needed")), /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm leading-7 text-slate-300" }, "Everything you need to explore Academy, follow public lessons, and see a preview of what the paid tiers include."), /* @__PURE__ */ React.createElement("div", { className: "mt-5 space-y-3 text-sm text-slate-300" }, [ + "Public lessons and Academy listings", + "Prompt previews and public documentation", + "Community access and updates", + "Upgrade to Creator or Pro anytime" + ].map((feature) => /* @__PURE__ */ React.createElement("div", { key: feature, className: "flex items-start gap-2.5 rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("span", { className: "mt-px shrink-0 text-slate-500" }, "✓"), /* @__PURE__ */ React.createElement("span", null, feature))))), products.map((product) => /* @__PURE__ */ React.createElement( + PlanCard, + { + key: product.tier, + product, + selectedPlan: product.selectedPlan, + currentTier, + isSubscribed, + activePlanKey, + billingEnabled, + loginHref, + manageHref: links.billingAccount, + onCheckout: handleCheckout + } + ))))); +} +const __vite_glob_0_2 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ + __proto__: null, + default: AcademyBillingPricing +}, Symbol.toStringTag, { value: "Module" })); +function AcademyBillingSuccess({ currentTier, isSubscribed, links = {} }) { + return /* @__PURE__ */ React.createElement("main", { className: "flex min-h-screen items-center bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(16,185,129,0.14),_transparent_24%),linear-gradient(180deg,_#07111f_0%,_#0f172a_45%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(Se$1, { title: "Subscription Confirmed" }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto w-full max-w-[640px] space-y-6" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[40px] border border-emerald-300/20 bg-[linear-gradient(135deg,rgba(7,17,31,0.95),rgba(12,24,45,0.92),rgba(6,78,59,0.82))] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.42)] md:p-10" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3" }, /* @__PURE__ */ React.createElement("span", { className: "text-3xl leading-none" }, "🎉"), isSubscribed ? /* @__PURE__ */ React.createElement(AccessBadge, { tier: currentTier }) : null), /* @__PURE__ */ React.createElement("h1", { className: "mt-5 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl" }, isSubscribed ? "Welcome to Academy." : "You're all set."), /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-lg text-base leading-8 text-slate-300" }, isSubscribed ? "Your subscription is active and all premium content for your plan is now unlocked. Head to Academy and start exploring." : "Your payment was confirmed and your subscription is activating now. This usually takes just a moment. If you don't see your access right away, refresh the Academy page in a few seconds.")), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement( + xe, + { + href: links.academy || "/academy", + className: "rounded-full border border-emerald-300/25 bg-emerald-300/12 px-5 py-3 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-300/18" + }, + "Go to Academy" + ), links.account ? /* @__PURE__ */ React.createElement( + xe, + { + href: links.account, + className: "rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]" + }, + "View my subscription" + ) : null))); +} +const __vite_glob_0_3 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ + __proto__: null, + default: AcademyBillingSuccess +}, Symbol.toStringTag, { value: "Module" })); function NovaSelect({ options = [], value, @@ -12710,18 +13197,34 @@ function AcademyChallengeSubmit({ seo, challenge, artworks, submitUrl }) { } ), form.errors.artwork_id ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-rose-300" }, form.errors.artwork_id) : null), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("label", { className: "text-sm font-semibold text-white" }, "Prompt used"), /* @__PURE__ */ React.createElement("textarea", { value: form.data.prompt_used, onChange: (event) => form.setData("prompt_used", event.target.value), rows: 5, className: "mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" })), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("label", { className: "text-sm font-semibold text-white" }, "Workflow notes"), /* @__PURE__ */ React.createElement("textarea", { value: form.data.workflow_notes, onChange: (event) => form.setData("workflow_notes", event.target.value), rows: 4, className: "mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" })), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("label", { className: "text-sm font-semibold text-white" }, "AI tool used"), /* @__PURE__ */ React.createElement("input", { value: form.data.ai_tool_used, onChange: (event) => form.setData("ai_tool_used", event.target.value), className: "mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("label", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("input", { type: "checkbox", checked: form.data.is_ai_generated, onChange: (event) => form.setData("is_ai_generated", event.target.checked) }), " AI-generated"), /* @__PURE__ */ React.createElement("label", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("input", { type: "checkbox", checked: form.data.is_ai_assisted, onChange: (event) => form.setData("is_ai_assisted", event.target.checked) }), " AI-assisted")), /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: form.processing, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, form.processing ? "Submitting..." : "Submit artwork")))); } -const __vite_glob_0_0 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_4 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AcademyChallengeSubmit }, Symbol.toStringTag, { value: "Module" })); -function CourseCard({ course, variant = "default" }) { +function CourseCard({ course, variant = "default", analytics = null, searchContext = null, position: position2 = null }) { const isFeatured = variant === "featured"; const progress = course?.progress || null; const cover = course?.cover_image_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image || ""; + const trackSearchClick = () => { + if (!searchContext?.query) { + return; + } + trackAcademySearchResultClick(analytics, searchContext, { + contentType: "academy_course", + contentId: course?.id, + position: position2 + }); + }; return /* @__PURE__ */ React.createElement( xe, { href: course.public_url, + onClick: trackSearchClick, + "data-academy-content-type": searchContext?.query ? "academy_course" : void 0, + "data-academy-content-id": searchContext?.query ? course?.id : void 0, + "data-academy-search-query": searchContext?.query || void 0, + "data-academy-search-results-count": searchContext?.resultsCount || void 0, + "data-academy-search-position": position2 || void 0, className: [ "group overflow-hidden rounded-[30px] border border-white/10 transition hover:border-sky-300/25 hover:bg-white/[0.06]", isFeatured ? "bg-[linear-gradient(135deg,rgba(14,165,233,0.14),rgba(15,23,42,0.92))]" : "bg-white/[0.04]" @@ -12731,8 +13234,15 @@ function CourseCard({ course, variant = "default" }) { /* @__PURE__ */ React.createElement("div", { className: "p-6" }, /* @__PURE__ */ React.createElement("h2", { className: `font-semibold tracking-[-0.05em] text-white transition group-hover:text-sky-100 ${isFeatured ? "text-3xl" : "text-2xl"}` }, course.title), course.subtitle ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-medium uppercase tracking-[0.18em] text-slate-400" }, course.subtitle) : null, /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm leading-7 text-slate-300" }, course.excerpt || course.description || "Structured Academy course."), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-3 sm:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500" }, "Lessons"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, course.lessons_count || 0)), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500" }, "Duration"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, course.estimated_minutes ? `${course.estimated_minutes} min` : "Flexible")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500" }, "Progress"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, progress ? `${progress.percent}%` : "Start fresh")))) ); } -function AcademyCoursesIndex({ seo, title, description, items, featuredCourses = [], filters = {}, pricingUrl }) { +function AcademyCoursesIndex({ seo, title, description, items, featuredCourses = [], filters = {}, pricingUrl, analytics }) { const flash = X$1().props.flash || {}; + useAcademyPageAnalytics(analytics); + const searchContext = analytics?.search ? { + query: analytics.search.query, + normalizedQuery: analytics.search.normalizedQuery, + resultsCount: analytics.search.resultsCount, + filters + } : null; const difficultyOptions = [ { value: "", label: "All levels" }, { value: "beginner", label: "Beginner" }, @@ -12745,7 +13255,7 @@ function AcademyCoursesIndex({ seo, title, description, items, featuredCourses = { value: "premium", label: "Premium" }, { value: "mixed", label: "Mixed" } ]; - return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { seo: seo || {}, title, description }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-[1400px] space-y-6" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.96),rgba(14,165,233,0.12))] p-8 shadow-[0_24px_90px_rgba(2,6,23,0.36)] md:p-10 lg:p-12" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-end justify-between gap-6" }, /* @__PURE__ */ React.createElement("div", { className: "max-w-4xl" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80" }, "Skinbase AI Academy"), /* @__PURE__ */ React.createElement("h1", { className: "mt-4 text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl lg:text-6xl" }, title), /* @__PURE__ */ React.createElement("p", { className: "mt-5 text-base leading-8 text-slate-300 md:text-lg" }, description)), /* @__PURE__ */ React.createElement(xe, { href: pricingUrl, className: "rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100" }, "See Academy plans"))), flash.success ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100" }, flash.success) : null, flash.error ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100" }, flash.error) : null, featuredCourses.length ? /* @__PURE__ */ React.createElement("section", { className: "grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]" }, /* @__PURE__ */ React.createElement(CourseCard, { course: featuredCourses[0], variant: "featured" }), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5" }, featuredCourses.slice(1, 3).map((course) => /* @__PURE__ */ React.createElement(CourseCard, { key: course.id, course })))) : null, /* @__PURE__ */ React.createElement("section", { className: "grid gap-3 rounded-[30px] border border-white/10 bg-black/20 p-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement( + return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { seo: seo || {}, title, description }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-[1400px] space-y-6" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.96),rgba(14,165,233,0.12))] p-8 shadow-[0_24px_90px_rgba(2,6,23,0.36)] md:p-10 lg:p-12" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-end justify-between gap-6" }, /* @__PURE__ */ React.createElement("div", { className: "max-w-4xl" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80" }, "Skinbase AI Academy"), /* @__PURE__ */ React.createElement("h1", { className: "mt-4 text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl lg:text-6xl" }, title), /* @__PURE__ */ React.createElement("p", { className: "mt-5 text-base leading-8 text-slate-300 md:text-lg" }, description)), /* @__PURE__ */ React.createElement(xe, { href: pricingUrl, onClick: () => trackUpgradeClick(analytics, { source: "academy_courses_index_hero" }), className: "rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100" }, "See Academy plans"))), flash.success ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100" }, flash.success) : null, flash.error ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100" }, flash.error) : null, featuredCourses.length ? /* @__PURE__ */ React.createElement("section", { className: "grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]" }, /* @__PURE__ */ React.createElement(CourseCard, { course: featuredCourses[0], variant: "featured", analytics, searchContext, position: 1 }), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5" }, featuredCourses.slice(1, 3).map((course, index2) => /* @__PURE__ */ React.createElement(CourseCard, { key: course.id, course, analytics, searchContext, position: index2 + 2 })))) : null, /* @__PURE__ */ React.createElement("section", { className: "grid gap-3 rounded-[30px] border border-white/10 bg-black/20 p-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement( NovaSelect, { label: "Difficulty", @@ -12765,9 +13275,9 @@ function AcademyCoursesIndex({ seo, title, description, items, featuredCourses = searchable: false, className: "rounded-2xl bg-white/[0.04]" } - )), (items?.data || []).length === 0 ? /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-400" }, "No published Academy courses matched these filters.") : /* @__PURE__ */ React.createElement("section", { className: "grid gap-5 md:grid-cols-2 xl:grid-cols-3" }, items.data.map((course) => /* @__PURE__ */ React.createElement(CourseCard, { key: course.id, course }))))); + )), (items?.data || []).length === 0 ? /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-400" }, "No published Academy courses matched these filters.") : /* @__PURE__ */ React.createElement("section", { className: "grid gap-5 md:grid-cols-2 xl:grid-cols-3" }, items.data.map((course, index2) => /* @__PURE__ */ React.createElement(CourseCard, { key: course.id, course, analytics, searchContext, position: index2 + 1 }))))); } -const __vite_glob_0_1 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_5 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AcademyCoursesIndex }, Symbol.toStringTag, { value: "Module" })); @@ -12812,10 +13322,15 @@ function SectionBlock({ section, isActive = false }) { if (!section?.is_visible) return null; return /* @__PURE__ */ React.createElement("section", { className: `rounded-[32px] border p-6 transition md:p-7 ${isActive ? "border-sky-300/25 bg-[linear-gradient(180deg,rgba(14,165,233,0.08),rgba(255,255,255,0.04))] shadow-[0_22px_50px_rgba(14,165,233,0.08)]" : "border-white/10 bg-white/[0.04]"}` }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Course section"), /* @__PURE__ */ React.createElement("span", { className: `rounded-full border px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${isActive ? "border-sky-300/20 bg-sky-300/12 text-sky-100" : "border-white/10 bg-black/20 text-slate-300"}` }, section.order_num + 1)), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, section.title), section.description ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 max-w-3xl text-sm leading-7 text-slate-300" }, section.description) : null), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-black/20 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-300" }, section.lessons?.length || 0, " lessons"), isActive ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-sky-300/20 bg-sky-300/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100" }, "Reading now") : null)), /* @__PURE__ */ React.createElement("div", { className: "mt-5 space-y-6" }, (section.lessons || []).map((lesson) => /* @__PURE__ */ React.createElement(LessonChip, { key: lesson.course_lesson_id || lesson.id, lesson })))); } -function AcademyCoursesShow({ seo, course, sections = [], unsectionedLessons = [], pricingUrl }) { +function AcademyCoursesShow({ seo, course, sections = [], unsectionedLessons = [], pricingUrl, startUrl = null, interaction = null, interactionRoutes = null, loginUrl = null, analytics = null }) { const flash = X$1().props.flash || {}; + useAcademyPageAnalytics(analytics); const cover = course?.cover_image_url || course?.cover_image || course?.teaser_image_url || course?.teaser_image || ""; const progress = course?.progress || null; + const [liked, setLiked] = reactExports.useState(Boolean(interaction?.liked)); + const [saved, setSaved] = reactExports.useState(Boolean(interaction?.saved)); + const [likesCount, setLikesCount] = reactExports.useState(Number(interaction?.likes_count || 0)); + const [savesCount, setSavesCount] = reactExports.useState(Number(interaction?.saves_count || 0)); const sectionJumpItems = reactExports.useMemo( () => [ ...unsectionedLessons.length ? [{ id: "course-outline-core", label: "Core lessons", count: unsectionedLessons.length }] : [], @@ -12848,7 +13363,53 @@ function AcademyCoursesShow({ seo, course, sections = [], unsectionedLessons = [ elements.forEach((element2) => observer.observe(element2)); return () => observer.disconnect(); }, [sectionJumpItems]); - return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { seo: seo || {}, title: course?.title, description: course?.excerpt || course?.description }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-[1400px] space-y-6" }, flash.success ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100" }, flash.success) : null, flash.error ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100" }, flash.error) : null, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[40px] border border-white/10 bg-black/20 shadow-[0_24px_90px_rgba(2,6,23,0.34)]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-0 xl:grid-cols-[minmax(0,1.2fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "relative overflow-hidden p-6 md:p-8 lg:p-10 xl:p-12" }, cover ? /* @__PURE__ */ React.createElement("img", { src: cover, alt: "", "aria-hidden": "true", className: "absolute inset-0 h-full w-full object-cover opacity-[0.18]" }) : null, /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(125,211,252,0.18),_transparent_28%),radial-gradient(circle_at_78%_26%,_rgba(251,191,36,0.12),_transparent_20%),linear-gradient(135deg,_rgba(2,6,23,0.98),_rgba(15,23,42,0.85))]" }), /* @__PURE__ */ React.createElement("div", { className: "relative z-10 max-w-5xl" }, /* @__PURE__ */ React.createElement(CourseBreadcrumbs, { items: breadcrumbs }), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap items-center gap-2.5" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100" }, "Academy course"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300" }, course?.difficulty), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300" }, course?.access_level), progress?.percent ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-emerald-300/20 bg-emerald-300/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-100" }, progress.percent, "% complete") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-6" }, /* @__PURE__ */ React.createElement("h1", { className: "text-4xl font-semibold tracking-[-0.06em] text-white md:text-5xl lg:text-[3.75rem]" }, course?.title), course?.subtitle ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm font-semibold uppercase tracking-[0.24em] text-amber-100/90" }, course.subtitle) : null, /* @__PURE__ */ React.createElement("p", { className: "mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg" }, course?.excerpt || course?.description), /* @__PURE__ */ React.createElement("div", { className: "mt-7 overflow-hidden rounded-[32px] border border-white/10 bg-slate-950/80 shadow-[0_24px_60px_rgba(2,6,23,0.32)]" }, cover ? /* @__PURE__ */ React.createElement("img", { src: cover, alt: "", "aria-hidden": "true", className: "w-full object-contain" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-[360px] items-center justify-center bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(15,23,42,0.92))] px-6 text-center text-sm text-slate-400" }, "No course cover image yet"))))), /* @__PURE__ */ React.createElement("aside", { className: "border-t border-white/10 bg-white/[0.03] p-6 xl:border-l xl:border-t-0 xl:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4 xl:sticky xl:top-6" }, /* @__PURE__ */ React.createElement(ProgressMeter, { progress }), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-black/20 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Jump through the course"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-2" }, sectionJumpItems.length ? sectionJumpItems.map((item) => /* @__PURE__ */ React.createElement( + const requireLogin = () => { + if (loginUrl && typeof window !== "undefined") { + window.location.href = loginUrl; + } + }; + const startCourse = () => { + if (!startUrl) { + requireLogin(); + return; + } + At.post(startUrl); + }; + const toggleLike = async () => { + if (!interactionRoutes?.like || !analytics?.contentType || !analytics?.contentId) { + return; + } + if (analytics?.isGuest) { + requireLogin(); + return; + } + const payload = await postAcademyAction(interactionRoutes.like, { + content_type: analytics.contentType, + content_id: analytics.contentId + }); + if (payload?.liked !== void 0) { + setLiked(Boolean(payload.liked)); + setLikesCount(Number(payload.likes_count || 0)); + } + }; + const toggleSave = async () => { + if (!interactionRoutes?.save || !analytics?.contentType || !analytics?.contentId) { + return; + } + if (analytics?.isGuest) { + requireLogin(); + return; + } + const payload = await postAcademyAction(interactionRoutes.save, { + content_type: analytics.contentType, + content_id: analytics.contentId + }); + if (payload?.saved !== void 0) { + setSaved(Boolean(payload.saved)); + setSavesCount(Number(payload.saves_count || 0)); + } + }; + return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { seo: seo || {}, title: course?.title, description: course?.excerpt || course?.description }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-[1400px] space-y-6" }, flash.success ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100" }, flash.success) : null, flash.error ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100" }, flash.error) : null, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[40px] border border-white/10 bg-black/20 shadow-[0_24px_90px_rgba(2,6,23,0.34)]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-0 xl:grid-cols-[minmax(0,1.2fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "relative overflow-hidden p-6 md:p-8 lg:p-10 xl:p-12" }, cover ? /* @__PURE__ */ React.createElement("img", { src: cover, alt: "", "aria-hidden": "true", className: "absolute inset-0 h-full w-full object-cover opacity-[0.18]" }) : null, /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(125,211,252,0.18),_transparent_28%),radial-gradient(circle_at_78%_26%,_rgba(251,191,36,0.12),_transparent_20%),linear-gradient(135deg,_rgba(2,6,23,0.98),_rgba(15,23,42,0.85))]" }), /* @__PURE__ */ React.createElement("div", { className: "relative z-10 max-w-5xl" }, /* @__PURE__ */ React.createElement(CourseBreadcrumbs, { items: breadcrumbs }), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap items-center gap-2.5" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100" }, "Academy course"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300" }, course?.difficulty), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300" }, course?.access_level), progress?.percent ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-emerald-300/20 bg-emerald-300/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-100" }, progress.percent, "% complete") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-6" }, /* @__PURE__ */ React.createElement("h1", { className: "text-4xl font-semibold tracking-[-0.06em] text-white md:text-5xl lg:text-[3.75rem]" }, course?.title), course?.subtitle ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm font-semibold uppercase tracking-[0.24em] text-amber-100/90" }, course.subtitle) : null, /* @__PURE__ */ React.createElement("p", { className: "mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg" }, course?.excerpt || course?.description), /* @__PURE__ */ React.createElement("div", { className: "mt-6 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: startCourse, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, progress?.percent ? "Continue course" : "Start course"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: toggleLike, className: "rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]" }, liked ? `Liked · ${likesCount}` : `Like · ${likesCount}`), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: toggleSave, className: "rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]" }, saved ? `Saved · ${savesCount}` : `Save · ${savesCount}`), /* @__PURE__ */ React.createElement(xe, { href: pricingUrl, onClick: () => trackUpgradeClick(analytics, { source: "academy_course_header" }), className: "rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100" }, "See plans")), /* @__PURE__ */ React.createElement("div", { className: "mt-7 overflow-hidden rounded-[32px] border border-white/10 bg-slate-950/80 shadow-[0_24px_60px_rgba(2,6,23,0.32)]" }, cover ? /* @__PURE__ */ React.createElement("img", { src: cover, alt: "", "aria-hidden": "true", className: "w-full object-contain" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-[360px] items-center justify-center bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(15,23,42,0.92))] px-6 text-center text-sm text-slate-400" }, "No course cover image yet"))))), /* @__PURE__ */ React.createElement("aside", { className: "border-t border-white/10 bg-white/[0.03] p-6 xl:border-l xl:border-t-0 xl:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4 xl:sticky xl:top-6" }, /* @__PURE__ */ React.createElement(ProgressMeter, { progress }), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-black/20 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Jump through the course"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-2" }, sectionJumpItems.length ? sectionJumpItems.map((item) => /* @__PURE__ */ React.createElement( "a", { key: item.id, @@ -12872,7 +13433,7 @@ function AcademyCoursesShow({ seo, course, sections = [], unsectionedLessons = [ } ) : null, sections.filter((section) => section?.is_visible).map((section) => /* @__PURE__ */ React.createElement(SectionBlock, { key: section.id, section, isActive: activeJumpId === `section-${section.id}` }))))); } -const __vite_glob_0_2 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_6 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AcademyCoursesShow }, Symbol.toStringTag, { value: "Module" })); @@ -12886,7 +13447,8 @@ function FeaturedCourseCard({ course }) { const cover = course?.cover_image_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image || ""; return /* @__PURE__ */ React.createElement(xe, { href: course.public_url, className: "group overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.04] transition hover:border-sky-300/25 hover:bg-white/[0.06]" }, /* @__PURE__ */ React.createElement("div", { className: "relative h-44 overflow-hidden bg-[linear-gradient(135deg,rgba(14,165,233,0.24),rgba(15,23,42,0.92))]" }, cover ? /* @__PURE__ */ React.createElement("img", { src: cover, alt: "", "aria-hidden": "true", className: "h-full w-full object-cover" }) : null, /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.82))]" }), /* @__PURE__ */ React.createElement("div", { className: "absolute left-4 top-4 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100" }, course.difficulty), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-200" }, course.access_level))), /* @__PURE__ */ React.createElement("div", { className: "p-5" }, /* @__PURE__ */ React.createElement("h3", { className: "text-2xl font-semibold tracking-[-0.04em] text-white transition group-hover:text-sky-100" }, course.title), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, course.excerpt || course.description || "Guided Academy course."), /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-xs uppercase tracking-[0.18em] text-slate-500" }, course.lessons_count || 0, " lessons · ", course.estimated_minutes ? `${course.estimated_minutes} min` : "Flexible duration"))); } -function AcademyIndex({ seo, pricingUrl, links, featureFlags, stats, featuredCourses, featuredLessons, featuredPrompts, featuredChallenges }) { +function AcademyIndex({ seo, pricingUrl, links, featureFlags, stats, featuredCourses, featuredLessons, featuredPrompts, featuredChallenges, analytics }) { + useAcademyPageAnalytics(analytics); const jsonLd = [{ "@context": "https://schema.org", "@type": "WebPage", @@ -12894,9 +13456,9 @@ function AcademyIndex({ seo, pricingUrl, links, featureFlags, stats, featuredCou description: seo?.description, url: seo?.canonical }]; - return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.18),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.18),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { seo: seo || {}, title: "Skinbase AI Academy", description: seo?.description, jsonLd }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-[1440px] space-y-8" }, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.94),rgba(28,25,23,0.88)),radial-gradient(circle_at_top_right,rgba(251,191,36,0.2),transparent_26%)] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.38)] md:p-10 lg:p-12" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-end" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-amber-200/80" }, "Skinbase AI Academy"), /* @__PURE__ */ React.createElement("h1", { className: "mt-4 max-w-4xl text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl xl:text-6xl" }, "Learn how to turn prompts into wallpapers, digital art, skins, covers, and visual worlds."), /* @__PURE__ */ React.createElement("p", { className: "mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg" }, "Skinbase AI Academy is the creative learning hub for AI-assisted art on Skinbase. Start with free lessons, explore prompt templates, and unlock premium workflows later."), /* @__PURE__ */ React.createElement("div", { className: "mt-7 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement(xe, { href: links.courses, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18" }, "Browse courses"), /* @__PURE__ */ React.createElement(xe, { href: links.lessons, className: "rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-300/40 hover:bg-amber-300/18" }, "Browse lessons"), /* @__PURE__ */ React.createElement(xe, { href: links.prompts, className: "rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]" }, "Open prompt library"), /* @__PURE__ */ React.createElement(xe, { href: pricingUrl, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18" }, "See plans"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[30px] border border-white/10 bg-black/20 p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300" }, "Launch status"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3" }, /* @__PURE__ */ React.createElement("span", null, "Challenges"), /* @__PURE__ */ React.createElement("span", null, featureFlags?.challengesEnabled ? "Enabled" : "Disabled")), /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3" }, /* @__PURE__ */ React.createElement("span", null, "Badges"), /* @__PURE__ */ React.createElement("span", null, featureFlags?.badgesEnabled ? "Enabled" : "Disabled")), /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3" }, /* @__PURE__ */ React.createElement("span", null, "Payments"), /* @__PURE__ */ React.createElement("span", null, featureFlags?.paymentsEnabled ? "Preview only" : "Disabled")))))), /* @__PURE__ */ React.createElement("section", { className: "grid gap-5 lg:grid-cols-3" }, /* @__PURE__ */ React.createElement(FeatureCard, { title: "Courses", description: "Follow guided learning paths that stitch reusable Academy lessons into a clean progression with completion tracking.", href: links.courses, cta: "Browse courses" }), /* @__PURE__ */ React.createElement(FeatureCard, { title: "Lessons", description: "Structured tutorials for prompt writing, cleanup workflows, AI ethics, and Skinbase-native publishing habits.", href: links.lessons, cta: "Open lessons" }), /* @__PURE__ */ React.createElement(FeatureCard, { title: "Prompt Library", description: "Discover reusable prompt templates, locked premium previews, and creator-focused visual workflows.", href: links.prompts, cta: "Explore prompts" })), /* @__PURE__ */ React.createElement("section", { className: "grid gap-5 lg:grid-cols-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Courses"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-4xl font-semibold tracking-[-0.05em] text-white" }, stats?.courseCount || 0)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Lessons"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-4xl font-semibold tracking-[-0.05em] text-white" }, stats?.lessonCount || 0)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Prompts"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-4xl font-semibold tracking-[-0.05em] text-white" }, stats?.promptCount || 0)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Challenges"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-4xl font-semibold tracking-[-0.05em] text-white" }, stats?.challengeCount || 0))), featuredCourses?.length ? /* @__PURE__ */ React.createElement("section", { className: "space-y-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-end justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Featured courses"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-3xl font-semibold tracking-[-0.045em] text-white" }, "Guided Academy paths")), /* @__PURE__ */ React.createElement(xe, { href: links.courses, className: "rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white" }, "All courses")), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 xl:grid-cols-3" }, featuredCourses.slice(0, 3).map((course) => /* @__PURE__ */ React.createElement(FeaturedCourseCard, { key: course.id, course })))) : null, /* @__PURE__ */ React.createElement("section", { className: "grid gap-5 xl:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-black/20 p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Featured lessons"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, (featuredLessons || []).slice(0, 3).map((item) => /* @__PURE__ */ React.createElement(xe, { key: item.id, href: academyHref$1("lessons", item.slug), className: "block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, item.lesson_label || "Featured lesson"), /* @__PURE__ */ React.createElement("span", { className: "mt-1 block" }, item.title))))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-black/20 p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Featured prompts"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, (featuredPrompts || []).slice(0, 3).map((item) => /* @__PURE__ */ React.createElement(xe, { key: item.id, href: academyHref$1("prompts", item.slug), className: "block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, item.title)))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-black/20 p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Current challenges"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, (featuredChallenges || []).slice(0, 3).map((item) => /* @__PURE__ */ React.createElement(xe, { key: item.id, href: academyHref$1("challenges", item.slug), className: "block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, item.title))))))); + return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.18),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.18),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { seo: seo || {}, title: "Skinbase AI Academy", description: seo?.description, jsonLd }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-[1440px] space-y-8" }, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.94),rgba(28,25,23,0.88)),radial-gradient(circle_at_top_right,rgba(251,191,36,0.2),transparent_26%)] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.38)] md:p-10 lg:p-12" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-end" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-amber-200/80" }, "Skinbase AI Academy"), /* @__PURE__ */ React.createElement("h1", { className: "mt-4 max-w-4xl text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl xl:text-6xl" }, "Learn how to turn prompts into wallpapers, digital art, skins, covers, and visual worlds."), /* @__PURE__ */ React.createElement("p", { className: "mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg" }, "Skinbase AI Academy is the creative learning hub for AI-assisted art on Skinbase. Start with free lessons, explore prompt templates, and unlock premium workflows later."), /* @__PURE__ */ React.createElement("div", { className: "mt-7 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement(xe, { href: links.courses, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18" }, "Browse courses"), /* @__PURE__ */ React.createElement(xe, { href: links.lessons, className: "rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-300/40 hover:bg-amber-300/18" }, "Browse lessons"), /* @__PURE__ */ React.createElement(xe, { href: links.prompts, className: "rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]" }, "Open prompt library"), /* @__PURE__ */ React.createElement(xe, { href: pricingUrl, onClick: () => trackUpgradeClick(analytics, { source: "academy_home_hero" }), className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18" }, "See plans"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[30px] border border-white/10 bg-black/20 p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300" }, "Launch status"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3" }, /* @__PURE__ */ React.createElement("span", null, "Challenges"), /* @__PURE__ */ React.createElement("span", null, featureFlags?.challengesEnabled ? "Enabled" : "Disabled")), /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3" }, /* @__PURE__ */ React.createElement("span", null, "Badges"), /* @__PURE__ */ React.createElement("span", null, featureFlags?.badgesEnabled ? "Enabled" : "Disabled")), /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3" }, /* @__PURE__ */ React.createElement("span", null, "Payments"), /* @__PURE__ */ React.createElement("span", null, featureFlags?.paymentsEnabled ? "Preview only" : "Disabled")))))), /* @__PURE__ */ React.createElement("section", { className: "grid gap-5 lg:grid-cols-3" }, /* @__PURE__ */ React.createElement(FeatureCard, { title: "Courses", description: "Follow guided learning paths that stitch reusable Academy lessons into a clean progression with completion tracking.", href: links.courses, cta: "Browse courses" }), /* @__PURE__ */ React.createElement(FeatureCard, { title: "Lessons", description: "Structured tutorials for prompt writing, cleanup workflows, AI ethics, and Skinbase-native publishing habits.", href: links.lessons, cta: "Open lessons" }), /* @__PURE__ */ React.createElement(FeatureCard, { title: "Prompt Library", description: "Discover reusable prompt templates, locked premium previews, and creator-focused visual workflows.", href: links.prompts, cta: "Explore prompts" })), /* @__PURE__ */ React.createElement("section", { className: "grid gap-5 lg:grid-cols-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Courses"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-4xl font-semibold tracking-[-0.05em] text-white" }, stats?.courseCount || 0)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Lessons"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-4xl font-semibold tracking-[-0.05em] text-white" }, stats?.lessonCount || 0)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Prompts"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-4xl font-semibold tracking-[-0.05em] text-white" }, stats?.promptCount || 0)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Challenges"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-4xl font-semibold tracking-[-0.05em] text-white" }, stats?.challengeCount || 0))), featuredCourses?.length ? /* @__PURE__ */ React.createElement("section", { className: "space-y-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-end justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Featured courses"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-3xl font-semibold tracking-[-0.045em] text-white" }, "Guided Academy paths")), /* @__PURE__ */ React.createElement(xe, { href: links.courses, className: "rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white" }, "All courses")), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 xl:grid-cols-3" }, featuredCourses.slice(0, 3).map((course) => /* @__PURE__ */ React.createElement(FeaturedCourseCard, { key: course.id, course })))) : null, /* @__PURE__ */ React.createElement("section", { className: "grid gap-5 xl:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-black/20 p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Featured lessons"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, (featuredLessons || []).slice(0, 3).map((item) => /* @__PURE__ */ React.createElement(xe, { key: item.id, href: academyHref$1("lessons", item.slug), className: "block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, item.lesson_label || "Featured lesson"), /* @__PURE__ */ React.createElement("span", { className: "mt-1 block" }, item.title))))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-black/20 p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Featured prompts"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, (featuredPrompts || []).slice(0, 3).map((item) => /* @__PURE__ */ React.createElement(xe, { key: item.id, href: academyHref$1("prompts", item.slug), className: "block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, item.title)))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-black/20 p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Current challenges"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, (featuredChallenges || []).slice(0, 3).map((item) => /* @__PURE__ */ React.createElement(xe, { key: item.id, href: academyHref$1("challenges", item.slug), className: "block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, item.title))))))); } -const __vite_glob_0_3 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_7 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AcademyIndex }, Symbol.toStringTag, { value: "Module" })); @@ -12958,39 +13520,168 @@ function itemHref$1(pageType, item) { if (pageType === "packs") return academyHref("packs", item.slug); return academyHref("challenges", item.slug); } -function PromptLibraryHero({ title, description, items, pricingUrl }) { - const featuredImages = (items || []).map((item) => item?.preview_image).filter(Boolean).slice(0, 3); - const primaryImage = featuredImages[0] || ""; - const supportingImages = featuredImages.slice(1, 3); - return /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[38px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.14),transparent_26%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.16),transparent_26%),linear-gradient(135deg,rgba(4,9,18,0.98),rgba(15,23,42,0.92))] p-8 shadow-[0_28px_90px_rgba(2,6,23,0.28)] md:p-10 lg:p-12" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-8 xl:grid-cols-[minmax(0,1.15fr)_420px] xl:items-end" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[#fff0ea]" }, "Skinbase AI Academy"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300" }, "Prompt Library")), /* @__PURE__ */ React.createElement("h1", { className: "mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl xl:text-6xl" }, title), /* @__PURE__ */ React.createElement("p", { className: "mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg" }, description), /* @__PURE__ */ React.createElement("div", { className: "mt-7 grid gap-3 sm:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Visual-first"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, "Preview prompt results before opening the detail page.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Reusable"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, "Templates for wallpapers, covers, worlds, portraits, and more.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Comparison-ready"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, "See which prompts include provider-specific notes and outputs."))), /* @__PURE__ */ React.createElement("div", { className: "mt-7 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement(xe, { href: pricingUrl, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, "Upgrade preview"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85" }, items?.length || 0, " prompts in view"))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3" }, primaryImage ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "overflow-hidden rounded-[28px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.18)] aspect-[16/10]" }, /* @__PURE__ */ React.createElement("img", { src: primaryImage, alt: "", "aria-hidden": "true", className: "h-full w-full object-cover" })), supportingImages.length ? /* @__PURE__ */ React.createElement("div", { className: `grid gap-3 ${supportingImages.length === 1 ? "grid-cols-1" : "grid-cols-2"}` }, supportingImages.map((image2, index2) => /* @__PURE__ */ React.createElement("div", { key: `${image2}-${index2}`, className: "overflow-hidden rounded-[28px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.18)] aspect-square" }, /* @__PURE__ */ React.createElement("img", { src: image2, alt: "", "aria-hidden": "true", className: "h-full w-full object-cover" })))) : null) : /* @__PURE__ */ React.createElement("div", { className: "col-span-2 flex aspect-[16/10] items-center justify-center rounded-[28px] border border-white/10 bg-[linear-gradient(135deg,rgba(56,189,248,0.12),rgba(17,24,39,0.92))] px-8 text-center text-sm font-semibold uppercase tracking-[0.24em] text-slate-300" }, "Prompt preview images will appear here")))); +function searchResultContentType(pageType) { + if (pageType === "prompts") return "academy_prompt"; + if (pageType === "lessons") return "academy_lesson"; + if (pageType === "packs") return "academy_prompt_pack"; + if (pageType === "challenges") return "academy_challenge"; + return null; } -function AcademyCard({ pageType, item }) { - const lessonSeries = String(item?.series_name || "").trim(); - const promptPreviewImage = item?.preview_image || ""; - if (pageType === "prompts") { - return /* @__PURE__ */ React.createElement(xe, { href: itemHref$1(pageType, item), className: "group overflow-hidden rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(7,11,18,0.96))] shadow-[0_20px_50px_rgba(2,6,23,0.18)] transition hover:border-sky-300/25 hover:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(10,15,26,0.98))]" }, /* @__PURE__ */ React.createElement("div", { className: "relative aspect-[16/11] overflow-hidden bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(17,24,39,0.94))]" }, promptPreviewImage ? /* @__PURE__ */ React.createElement("img", { src: promptPreviewImage, alt: "", "aria-hidden": "true", className: "h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" }) : null, /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.72))]" }), /* @__PURE__ */ React.createElement("div", { className: "absolute left-4 top-4 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[#fff0ea]" }, "Prompt template"), /* @__PURE__ */ React.createElement(LockBadge, { item })), /* @__PURE__ */ React.createElement("div", { className: "absolute bottom-4 left-4 right-4 flex flex-wrap items-end justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, item?.difficulty ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white" }, item.difficulty) : null, item?.aspect_ratio ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white" }, item.aspect_ratio) : null))), /* @__PURE__ */ React.createElement("div", { className: "p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, item?.category?.name || "Academy"), Array.isArray(item?.tool_notes) && item.tool_notes.length ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-[#ffcfbf]/15 bg-[#ffcfbf]/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[#fff0ea]" }, item.tool_notes.length, " comparisons") : null), /* @__PURE__ */ React.createElement("h2", { className: "mt-3 text-2xl font-semibold tracking-[-0.04em] text-white transition group-hover:text-sky-100" }, item.title), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, item.excerpt || item.description || item.prompt_preview || "No description yet."), item.tags?.length ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-xs uppercase tracking-[0.18em] text-slate-500" }, item.tags.slice(0, 4).join(" · ")) : null)); +function promptPreviewAsset(item) { + const full = item?.preview_image || ""; + const thumb = item?.preview_image_thumb || full; + if (!thumb) { + return null; } - return /* @__PURE__ */ React.createElement(xe, { href: itemHref$1(pageType, item), className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-5 transition hover:border-white/20 hover:bg-white/[0.06]" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, pageType.slice(0, -1)), /* @__PURE__ */ React.createElement(LockBadge, { item })), pageType === "lessons" && item?.formatted_lesson_number ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100" }, item.formatted_lesson_number), lessonSeries ? /* @__PURE__ */ React.createElement("span", { className: "text-xs font-medium uppercase tracking-[0.18em] text-slate-500" }, lessonSeries) : null) : null, /* @__PURE__ */ React.createElement("h2", { className: "mt-4 text-2xl font-semibold tracking-[-0.04em] text-white" }, item.title), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, item.excerpt || item.description || item.prompt_preview || item.content_preview || "No description yet."), pageType === "lessons" && item.tags?.length ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-xs uppercase tracking-[0.18em] text-slate-500" }, item.tags.slice(0, 4).join(" · ")) : null, pageType === "prompts" && item.tags?.length ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-xs uppercase tracking-[0.18em] text-slate-500" }, item.tags.slice(0, 4).join(" · ")) : null, pageType === "challenges" ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-xs uppercase tracking-[0.18em] text-slate-500" }, item.status, " · ", item.submission_count ?? 0, " submissions") : null); + return { + src: thumb, + srcSet: item?.preview_image_srcset || "" + }; } -function AcademyList({ pageType, title, description, seo, items, filters, categories, pricingUrl }) { +function PromptLibraryHero({ title, description, items, pricingUrl, totalCount }) { + const featuredImages = (items || []).map((item) => promptPreviewAsset(item)).filter(Boolean).slice(0, 3); + const primaryImage = featuredImages[0] || null; + const supportingImages = featuredImages.slice(1, 3); + return /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[38px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.14),transparent_26%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.16),transparent_26%),linear-gradient(135deg,rgba(4,9,18,0.98),rgba(15,23,42,0.92))] p-8 shadow-[0_28px_90px_rgba(2,6,23,0.28)] md:p-10 lg:p-12" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-8 xl:grid-cols-[minmax(0,1.15fr)_420px] xl:items-end" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[#fff0ea]" }, "Skinbase AI Academy"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300" }, "Prompt Library")), /* @__PURE__ */ React.createElement("h1", { className: "mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl xl:text-6xl" }, title), /* @__PURE__ */ React.createElement("p", { className: "mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg" }, description), /* @__PURE__ */ React.createElement("div", { className: "mt-7 grid gap-3 sm:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Visual-first"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, "Preview prompt results before opening the detail page.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Reusable"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, "Templates for wallpapers, covers, worlds, portraits, and more.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Comparison-ready"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, "See which prompts include provider-specific notes and outputs."))), /* @__PURE__ */ React.createElement("div", { className: "mt-7 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement(xe, { href: pricingUrl, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, "Upgrade preview"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85" }, totalCount || 0, " prompts available"))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3" }, primaryImage ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "overflow-hidden rounded-[28px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.18)] aspect-[16/10]" }, /* @__PURE__ */ React.createElement("img", { src: primaryImage.src, srcSet: primaryImage.srcSet || void 0, sizes: "(max-width: 1279px) calc(100vw - 4rem), 420px", alt: "", "aria-hidden": "true", className: "h-full w-full object-cover" })), supportingImages.length ? /* @__PURE__ */ React.createElement("div", { className: `grid gap-3 ${supportingImages.length === 1 ? "grid-cols-1" : "grid-cols-2"}` }, supportingImages.map((image2, index2) => /* @__PURE__ */ React.createElement("div", { key: `${image2.src}-${index2}`, className: "overflow-hidden rounded-[28px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.18)] aspect-square" }, /* @__PURE__ */ React.createElement("img", { src: image2.src, srcSet: image2.srcSet || void 0, sizes: "(max-width: 1279px) calc(50vw - 2rem), 200px", alt: "", "aria-hidden": "true", className: "h-full w-full object-cover" })))) : null) : /* @__PURE__ */ React.createElement("div", { className: "col-span-2 flex aspect-[16/10] items-center justify-center rounded-[28px] border border-white/10 bg-[linear-gradient(135deg,rgba(56,189,248,0.12),rgba(17,24,39,0.92))] px-8 text-center text-sm font-semibold uppercase tracking-[0.24em] text-slate-300" }, "Prompt preview images will appear here")))); +} +function AcademyCard({ pageType, item, analytics, searchContext, position: position2 }) { + const lessonSeries = String(item?.series_name || "").trim(); + const promptPreviewImage = item?.preview_image_thumb || item?.preview_image || ""; + const promptPreviewSrcSet = item?.preview_image_srcset || ""; + const contentType = searchResultContentType(pageType); + const href = itemHref$1(pageType, item); + const trackSearchClick = () => { + if (!searchContext?.query || !contentType) { + return; + } + trackAcademySearchResultClick(analytics, searchContext, { + contentType, + contentId: item?.id, + position: position2 + }); + }; + if (pageType === "prompts") { + return /* @__PURE__ */ React.createElement( + xe, + { + href, + onClick: trackSearchClick, + "data-academy-content-type": contentType || void 0, + "data-academy-content-id": item?.id || void 0, + "data-academy-search-query": searchContext?.query || void 0, + "data-academy-search-results-count": searchContext?.resultsCount || void 0, + "data-academy-search-position": position2 || void 0, + className: "group overflow-hidden rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(7,11,18,0.96))] shadow-[0_20px_50px_rgba(2,6,23,0.18)] transition hover:border-sky-300/25 hover:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(10,15,26,0.98))]" + }, + /* @__PURE__ */ React.createElement("div", { className: "relative aspect-[16/11] overflow-hidden bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(17,24,39,0.94))]" }, promptPreviewImage ? /* @__PURE__ */ React.createElement("img", { src: promptPreviewImage, srcSet: promptPreviewSrcSet || void 0, sizes: "(max-width: 767px) calc(100vw - 2rem), (max-width: 1279px) calc(50vw - 2rem), 420px", alt: "", "aria-hidden": "true", className: "h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" }) : null, /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.72))]" }), /* @__PURE__ */ React.createElement("div", { className: "absolute left-4 top-4 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[#fff0ea]" }, "Prompt template"), /* @__PURE__ */ React.createElement(LockBadge, { item })), /* @__PURE__ */ React.createElement("div", { className: "absolute bottom-4 left-4 right-4 flex flex-wrap items-end justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, item?.difficulty ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white" }, item.difficulty) : null, item?.aspect_ratio ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white" }, item.aspect_ratio) : null))), + /* @__PURE__ */ React.createElement("div", { className: "p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, item?.category?.name || "Academy"), Array.isArray(item?.tool_notes) && item.tool_notes.length ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-[#ffcfbf]/15 bg-[#ffcfbf]/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[#fff0ea]" }, item.tool_notes.length, " comparisons") : null), /* @__PURE__ */ React.createElement("h2", { className: "mt-3 text-2xl font-semibold tracking-[-0.04em] text-white transition group-hover:text-sky-100" }, item.title), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, item.excerpt || item.description || item.prompt_preview || "No description yet."), item.tags?.length ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-xs uppercase tracking-[0.18em] text-slate-500" }, item.tags.slice(0, 4).join(" · ")) : null) + ); + } + return /* @__PURE__ */ React.createElement( + xe, + { + href, + onClick: trackSearchClick, + "data-academy-content-type": contentType || void 0, + "data-academy-content-id": item?.id || void 0, + "data-academy-search-query": searchContext?.query || void 0, + "data-academy-search-results-count": searchContext?.resultsCount || void 0, + "data-academy-search-position": position2 || void 0, + className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-5 transition hover:border-white/20 hover:bg-white/[0.06]" + }, + /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, pageType.slice(0, -1)), /* @__PURE__ */ React.createElement(LockBadge, { item })), + pageType === "lessons" && item?.formatted_lesson_number ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100" }, item.formatted_lesson_number), lessonSeries ? /* @__PURE__ */ React.createElement("span", { className: "text-xs font-medium uppercase tracking-[0.18em] text-slate-500" }, lessonSeries) : null) : null, + /* @__PURE__ */ React.createElement("h2", { className: "mt-4 text-2xl font-semibold tracking-[-0.04em] text-white" }, item.title), + /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, item.excerpt || item.description || item.prompt_preview || item.content_preview || "No description yet."), + pageType === "lessons" && item.tags?.length ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-xs uppercase tracking-[0.18em] text-slate-500" }, item.tags.slice(0, 4).join(" · ")) : null, + pageType === "prompts" && item.tags?.length ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-xs uppercase tracking-[0.18em] text-slate-500" }, item.tags.slice(0, 4).join(" · ")) : null, + pageType === "challenges" ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-xs uppercase tracking-[0.18em] text-slate-500" }, item.status, " · ", item.submission_count ?? 0, " submissions") : null + ); +} +async function fetchAcademyPage(url) { + const response = await fetch(url, { + headers: { + Accept: "application/json", + "X-Requested-With": "XMLHttpRequest" + }, + credentials: "same-origin" + }); + if (!response.ok) { + throw new Error("Failed to load the next page."); + } + return response.json(); +} +function AcademyList({ pageType, title, description, seo, items, filters, categories, pricingUrl, analytics }) { const flash = X$1().props.flash || {}; - const visibleItems = Array.isArray(items?.data) ? items.data : []; - return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.15),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { seo: seo || {}, title, description }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-[1360px] space-y-6" }, pageType === "prompts" ? /* @__PURE__ */ React.createElement(PromptLibraryHero, { title, description, items: visibleItems, pricingUrl }) : /* @__PURE__ */ React.createElement("section", { className: "rounded-[38px] border border-white/10 bg-black/20 p-8 md:p-10" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-end justify-between gap-5" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80" }, "Skinbase AI Academy"), /* @__PURE__ */ React.createElement("h1", { className: "mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl" }, title), /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-3xl text-base leading-8 text-slate-300" }, description)), /* @__PURE__ */ React.createElement(xe, { href: pricingUrl, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, "Upgrade preview"))), flash.success ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100" }, flash.success) : null, flash.error ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100" }, flash.error) : null, /* @__PURE__ */ React.createElement(QueryFilters, { pageType, filters, categories }), visibleItems.length === 0 ? /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-400" }, "Nothing matched this Academy view yet.") : /* @__PURE__ */ React.createElement("section", { className: "grid gap-5 md:grid-cols-2 xl:grid-cols-3" }, visibleItems.map((item) => /* @__PURE__ */ React.createElement(AcademyCard, { key: `${pageType}-${item.id}`, pageType, item }))))); + useAcademyPageAnalytics(analytics); + const searchContext = analytics?.search ? { + query: analytics.search.query, + normalizedQuery: analytics.search.normalizedQuery, + resultsCount: analytics.search.resultsCount, + filters + } : null; + const initialItems = React.useMemo(() => Array.isArray(items?.data) ? items.data : [], [items]); + const [visibleItems, setVisibleItems] = React.useState(initialItems); + const [pagination, setPagination] = React.useState({ + currentPage: Number(items?.current_page || 1), + lastPage: Number(items?.last_page || 1), + prevPageUrl: items?.prev_page_url || null, + nextPageUrl: items?.next_page_url || null + }); + const [loadingMore, setLoadingMore] = React.useState(false); + const sentinelRef = React.useRef(null); + React.useEffect(() => { + setVisibleItems(initialItems); + setPagination({ + currentPage: Number(items?.current_page || 1), + lastPage: Number(items?.last_page || 1), + prevPageUrl: items?.prev_page_url || null, + nextPageUrl: items?.next_page_url || null + }); + setLoadingMore(false); + }, [initialItems, items?.current_page, items?.last_page, items?.next_page_url, items?.prev_page_url, pageType]); + const hasMorePages = pageType === "prompts" && pagination.currentPage < pagination.lastPage && Boolean(pagination.nextPageUrl); + const hasFallbackPagination = pageType === "prompts" && pagination.lastPage > 1; + const loadMore = React.useCallback(async () => { + if (pageType !== "prompts" || loadingMore || !pagination.nextPageUrl) { + return; + } + setLoadingMore(true); + try { + const payload = await fetchAcademyPage(pagination.nextPageUrl); + const nextItems = Array.isArray(payload?.data) ? payload.data : []; + setVisibleItems((current) => [...current, ...nextItems.filter((item) => !current.some((existing) => String(existing.id) === String(item.id)))]); + setPagination({ + currentPage: Number(payload?.current_page || pagination.currentPage), + lastPage: Number(payload?.last_page || pagination.lastPage), + prevPageUrl: payload?.prev_page_url || pagination.prevPageUrl, + nextPageUrl: payload?.next_page_url || null + }); + } catch { + setPagination((current) => ({ ...current, nextPageUrl: null })); + } finally { + setLoadingMore(false); + } + }, [loadingMore, pageType, pagination.currentPage, pagination.lastPage, pagination.nextPageUrl, pagination.prevPageUrl]); + React.useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel || !hasMorePages || loadingMore || typeof window === "undefined" || typeof window.IntersectionObserver !== "function") { + return void 0; + } + const observer = new window.IntersectionObserver((entries) => { + if (entries[0]?.isIntersecting) { + void loadMore(); + } + }, { rootMargin: "360px 0px" }); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasMorePages, loadMore, loadingMore]); + return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.15),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { seo: seo || {}, title, description }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-[1360px] space-y-6" }, pageType === "prompts" ? /* @__PURE__ */ React.createElement(PromptLibraryHero, { title, description, items: visibleItems, pricingUrl, totalCount: Number(items?.total || visibleItems.length || 0) }) : /* @__PURE__ */ React.createElement("section", { className: "rounded-[38px] border border-white/10 bg-black/20 p-8 md:p-10" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-end justify-between gap-5" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80" }, "Skinbase AI Academy"), /* @__PURE__ */ React.createElement("h1", { className: "mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl" }, title), /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-3xl text-base leading-8 text-slate-300" }, description)), /* @__PURE__ */ React.createElement(xe, { href: pricingUrl, onClick: () => trackUpgradeClick(analytics, { source: `${pageType}_list_hero` }), className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, "Upgrade preview"))), flash.success ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100" }, flash.success) : null, flash.error ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100" }, flash.error) : null, /* @__PURE__ */ React.createElement(QueryFilters, { pageType, filters, categories }), visibleItems.length === 0 ? /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-400" }, "Nothing matched this Academy view yet.") : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("section", { className: "grid gap-5 md:grid-cols-2 xl:grid-cols-3" }, visibleItems.map((item, index2) => /* @__PURE__ */ React.createElement(AcademyCard, { key: `${pageType}-${item.id}`, pageType, item, analytics, searchContext, position: index2 + 1 }))), pageType === "prompts" ? /* @__PURE__ */ React.createElement("div", { className: "pt-2" }, /* @__PURE__ */ React.createElement("div", { ref: sentinelRef, className: "h-10 w-full", "aria-hidden": "true" }), loadingMore ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-black/20 px-5 py-4 text-center text-sm text-slate-300" }, "Loading more prompts...") : null, !hasMorePages && visibleItems.length > initialItems.length ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-black/20 px-5 py-4 text-center text-sm text-slate-400" }, "You have reached the end of the prompt library.") : null, hasFallbackPagination ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap items-center justify-between gap-3 rounded-[22px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400" }, "Auto-load is primary. Pagination is available as a backup."), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-3" }, pagination.prevPageUrl ? /* @__PURE__ */ React.createElement(xe, { href: pagination.prevPageUrl, preserveScroll: true, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-left text-[10px]" }), "Previous") : null, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-slate-300" }, "Page ", pagination.currentPage || 1, " of ", pagination.lastPage || 1), pagination.nextPageUrl ? /* @__PURE__ */ React.createElement(xe, { href: pagination.nextPageUrl, preserveScroll: true, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]" }, "Next", /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-right text-[10px]" })) : null)) : null) : null))); } -const __vite_glob_0_4 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_8 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AcademyList }, Symbol.toStringTag, { value: "Module" })); -function PlanCard({ plan, paymentsEnabled }) { - return /* @__PURE__ */ React.createElement("article", { className: "rounded-[30px] border border-white/10 bg-white/[0.04] p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold tracking-[-0.04em] text-white" }, plan.name), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-300" }, plan.badge)), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex items-end gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "text-4xl font-semibold tracking-[-0.05em] text-white" }, plan.price), /* @__PURE__ */ React.createElement("span", { className: "pb-1 text-sm text-slate-400" }, plan.interval)), /* @__PURE__ */ React.createElement("div", { className: "mt-6 space-y-3 text-sm text-slate-300" }, plan.features.map((feature) => /* @__PURE__ */ React.createElement("div", { key: feature, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, feature))), /* @__PURE__ */ React.createElement("button", { type: "button", disabled: true, className: "mt-6 w-full rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 opacity-100" }, paymentsEnabled ? "Checkout coming next phase" : "Payments disabled for this launch")); -} -function AcademyPricing({ seo, plans, paymentsEnabled }) { - return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#111827_0%,_#0f172a_100%)] px-4 py-8 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { seo: seo || {}, title: "Skinbase AI Academy Pricing", description: seo?.description }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-[1320px] space-y-8" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[38px] border border-white/10 bg-black/20 p-8 md:p-10" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Plans"), /* @__PURE__ */ React.createElement("h1", { className: "mt-4 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl" }, "Choose your AI Academy plan."), /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-3xl text-base leading-8 text-slate-300" }, "Start free, unlock Creator and Pro previews, and keep the billing flow disabled until Stripe and Cashier are introduced in the next phase.")), /* @__PURE__ */ React.createElement("section", { className: "grid gap-5 lg:grid-cols-3" }, plans.map((plan) => /* @__PURE__ */ React.createElement(PlanCard, { key: plan.name, plan, paymentsEnabled }))))); -} -const __vite_glob_0_5 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ - __proto__: null, - default: AcademyPricing -}, Symbol.toStringTag, { value: "Module" })); function AcademyBreadcrumbs({ items = [] }) { if (!items.length) return null; return /* @__PURE__ */ React.createElement("nav", { "aria-label": "Breadcrumb", className: "flex flex-wrap items-center gap-2 text-sm text-slate-400" }, items.map((item, index2) => { @@ -13012,8 +13703,45 @@ function formatLessonMinutes(minutes) { const value = Number(minutes || 0); return value > 0 ? `${value} min read` : "Quick read"; } -function StatPill$2({ label, value }) { - return /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, label), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, value)); +function normalizePromptAccessLevel(accessLevel) { + const value = String(accessLevel || "free").trim().toLowerCase(); + return value === "creator" || value === "pro" ? value : "free"; +} +function promptRequirementText(accessLevel) { + const level = normalizePromptAccessLevel(accessLevel); + if (level === "pro") return "Requires Pro access."; + if (level === "creator") return "Requires Creator or Pro access."; + return null; +} +function promptUnlockHeading(accessLevel) { + const level = normalizePromptAccessLevel(accessLevel); + if (level === "pro") return "Unlock the full Pro prompt."; + if (level === "creator") return "Unlock the full Creator prompt."; + return "Unlock the full prompt."; +} +function promptUnlockDescription(accessLevel) { + const level = normalizePromptAccessLevel(accessLevel); + if (level === "pro") { + return "Get the complete reusable prompt, negative prompt, workflow notes, model settings, and variation strategy."; + } + if (level === "creator") { + return "Get the complete reusable prompt, negative prompt, workflow notes, and creative workflow."; + } + return "Get the complete reusable prompt and workflow notes."; +} +function promptInlineImage(url, thumbUrl) { + return thumbUrl || url || ""; +} +function formatMetaDisplay(value) { + const normalized = String(value || "").trim(); + if (!normalized) return ""; + return normalized.replace(/[_-]+/g, " ").replace(/\b\w/g, (character) => character.toUpperCase()); +} +function StatPill$2({ label, value, icon, accentClassName = "border-white/10 bg-white/[0.04] text-slate-300", valueClassName = "text-white" }) { + return /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(15,23,42,0.18))] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, label), /* @__PURE__ */ React.createElement("p", { className: `mt-3 text-[clamp(1.35rem,2.2vw,2rem)] font-semibold tracking-[-0.04em] ${valueClassName}` }, value)), icon ? /* @__PURE__ */ React.createElement("span", { className: `mt-1 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[18px] border ${accentClassName}`, "aria-hidden": "true" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${icon} text-xs` })) : null)); +} +function PromptHeaderStat({ label, value, icon, accentClassName = "border-white/10 bg-white/[0.04] text-slate-300", valueClassName = "text-white" }) { + return /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(15,23,42,0.16))] px-4 py-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("p", { className: "text-[9px] font-semibold uppercase tracking-[0.2em] text-slate-400" }, label), /* @__PURE__ */ React.createElement("p", { className: `mt-2 break-words text-[clamp(0.75rem,0.95vw,0.95rem)] font-semibold leading-[1.25] tracking-[-0.03em] ${valueClassName}` }, value)), icon ? /* @__PURE__ */ React.createElement("span", { className: `inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[14px] border ${accentClassName}`, "aria-hidden": "true" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${icon} text-[10px]` })) : null)); } function LessonInfoRow({ label, value }) { return /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-4 rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, label), /* @__PURE__ */ React.createElement("span", { className: "text-sm font-semibold text-white" }, value)); @@ -13034,8 +13762,10 @@ function LessonNavCard({ direction, lesson }) { /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm leading-7 text-slate-300" }, lesson.excerpt || lesson.content_preview || "Open the next step in this Academy sequence.") ); } -function LockedPanel({ pricingUrl, label }) { - return /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-amber-300/20 bg-amber-300/10 p-6 text-amber-50" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80" }, "Premium content"), /* @__PURE__ */ React.createElement("h2", { className: "mt-3 text-2xl font-semibold tracking-[-0.04em]" }, "Unlock the full ", label, "."), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-amber-50/90" }, "This preview is visible, but the full Academy content stays server-side until your account has the required Creator or Pro access."), /* @__PURE__ */ React.createElement(xe, { href: pricingUrl, className: "mt-5 inline-flex rounded-full border border-amber-200/25 bg-white/10 px-5 py-3 text-sm font-semibold text-white" }, "See Academy plans")); +function LockedPanel({ pricingUrl, label, accessLevel, onUpgrade }) { + const isPrompt = label === "prompt"; + const requirement = promptRequirementText(accessLevel); + return /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-amber-300/20 bg-amber-300/10 p-6 text-amber-50" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80" }, "Premium content"), /* @__PURE__ */ React.createElement("h2", { className: "mt-3 text-2xl font-semibold tracking-[-0.04em]" }, isPrompt ? promptUnlockHeading(accessLevel) : `Unlock the full ${label}.`), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-amber-50/90" }, isPrompt ? promptUnlockDescription(accessLevel) : "This preview is visible, but the full Academy content stays server-side until your account has the required access."), requirement ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-xs font-semibold uppercase tracking-[0.18em] text-amber-100" }, requirement) : null, /* @__PURE__ */ React.createElement(xe, { href: pricingUrl, onClick: onUpgrade, className: "mt-5 inline-flex rounded-full border border-amber-200/25 bg-white/10 px-5 py-3 text-sm font-semibold text-white" }, "See Academy plans")); } function copyTextToClipboard$1(text2) { const source = String(text2 || ""); @@ -13060,7 +13790,7 @@ function copyTextToClipboard$1(text2) { } return Promise.reject(new Error("Clipboard unavailable")); } -function PromptCopyButton({ prompt, label = "Copy prompt" }) { +function PromptCopyButton({ prompt, label = "Copy prompt", analytics = null, contentId = null, eventType = "academy_prompt_copy", metadata = {} }) { const [status2, setStatus] = reactExports.useState("idle"); const resetTimerRef = reactExports.useRef(0); return /* @__PURE__ */ React.createElement( @@ -13068,7 +13798,14 @@ function PromptCopyButton({ prompt, label = "Copy prompt" }) { { type: "button", onClick: () => { - copyTextToClipboard$1(prompt).then(() => setStatus("copied")).catch(() => setStatus("failed")).finally(() => { + copyTextToClipboard$1(prompt).then(() => { + setStatus("copied"); + void trackAcademyEvent(eventType, analytics?.contentType || null, contentId || analytics?.contentId || null, metadata, { + url: analytics?.eventUrl, + pageName: analytics?.pageName, + useBeacon: false + }); + }).catch(() => setStatus("failed")).finally(() => { window.clearTimeout(resetTimerRef.current); resetTimerRef.current = window.setTimeout(() => setStatus("idle"), 1800); }); @@ -13126,10 +13863,12 @@ function ImageLightbox({ gallery, onClose, onNavigate }) { } function PromptToolNoteCard({ note, index: index2, galleryIndex, onOpenImage }) { if (!note || typeof note !== "object") return null; - const title = note.model_name || note.provider || `Comparison ${String(index2 + 1).padStart(2, "0")}`; + const displayType = String(note.display_type || "").trim(); + const eyebrowLabel = displayType || "AI comparison"; + const title = note.model_name || note.provider || `${displayType || "Comparison"} ${String(index2 + 1).padStart(2, "0")}`; const subtitle = [note.provider, note.model_name].filter(Boolean).join(" · "); - const previewUrl = note.image_url || note.thumb_url || ""; - const hasContent = Boolean(note.notes || note.strengths || note.weaknesses || note.best_for || note.settings || previewUrl || note.score || subtitle); + const previewUrl = promptInlineImage(note.image_url, note.thumb_url); + const hasContent = Boolean(displayType || note.notes || note.strengths || note.weaknesses || note.best_for || note.settings || previewUrl || note.score || subtitle); if (!hasContent) return null; return /* @__PURE__ */ React.createElement("article", { className: "rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(15,23,42,0.22))] p-5 shadow-[0_16px_40px_rgba(2,6,23,0.16)]" }, previewUrl ? /* @__PURE__ */ React.createElement( "button", @@ -13139,8 +13878,105 @@ function PromptToolNoteCard({ note, index: index2, galleryIndex, onOpenImage }) className: "group mb-5 block w-full overflow-hidden rounded-[24px] border border-white/10 bg-slate-950 text-left transition hover:border-sky-300/25 focus:outline-none focus:ring-2 focus:ring-sky-300/35", "aria-label": `Open comparison image for ${title}` }, - /* @__PURE__ */ React.createElement("div", { className: "relative" }, /* @__PURE__ */ React.createElement("img", { src: previewUrl, alt: title, loading: "lazy", className: "aspect-[4/3] w-full object-cover transition duration-500 group-hover:scale-[1.03]" }), /* @__PURE__ */ React.createElement("div", { className: "absolute inset-x-0 bottom-0 flex items-center justify-between gap-3 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.72))] px-4 py-3" }, /* @__PURE__ */ React.createElement("span", { className: "text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-100/90" }, "Click to zoom"), /* @__PURE__ */ React.createElement("span", { className: "inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/25 text-white" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-expand" })))) - ) : null, /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-[#ffcfbf]" }, "AI comparison"), /* @__PURE__ */ React.createElement("h3", { className: "mt-2 text-xl font-semibold tracking-[-0.03em] text-white" }, title), subtitle ? /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, subtitle) : null), /* @__PURE__ */ React.createElement("div", { className: "flex flex-col items-end gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, String(index2 + 1).padStart(2, "0")), note.score ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-xs font-semibold text-[#fff0ea]" }, `Score ${note.score}/10`) : null)), /* @__PURE__ */ React.createElement("div", { className: "mt-5 space-y-4" }, note.settings ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-black/25 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400" }, "Generated in"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-200" }, note.settings)) : null, note.notes ? /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400" }, "Overall notes"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-200" }, note.notes)) : null, note.best_for ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-sky-300/15 bg-sky-300/10 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100" }, "Best for"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-100" }, note.best_for)) : null, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, note.strengths ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-emerald-300/15 bg-emerald-300/10 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-100" }, "Strengths"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-100" }, note.strengths)) : null, note.weaknesses ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-amber-300/15 bg-amber-300/10 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, "Weaknesses"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-100" }, note.weaknesses)) : null))); + /* @__PURE__ */ React.createElement("div", { className: "relative" }, /* @__PURE__ */ React.createElement("img", { src: previewUrl, srcSet: note.image_srcset || void 0, sizes: "(max-width: 767px) calc(100vw - 4rem), (max-width: 1535px) calc(50vw - 3rem), 560px", alt: title, loading: "lazy", className: "aspect-[4/3] w-full object-cover transition duration-500 group-hover:scale-[1.03]" }), /* @__PURE__ */ React.createElement("div", { className: "absolute inset-x-0 bottom-0 flex items-center justify-between gap-3 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.72))] px-4 py-3" }, /* @__PURE__ */ React.createElement("span", { className: "text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-100/90" }, "Click to zoom"), /* @__PURE__ */ React.createElement("span", { className: "inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/25 text-white" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-expand" })))) + ) : null, /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-[#ffcfbf]" }, eyebrowLabel), /* @__PURE__ */ React.createElement("h3", { className: "mt-2 text-xl font-semibold tracking-[-0.03em] text-white" }, title), subtitle ? /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, subtitle) : null), /* @__PURE__ */ React.createElement("div", { className: "flex flex-col items-end gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, String(index2 + 1).padStart(2, "0")), note.score ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-xs font-semibold text-[#fff0ea]" }, `Score ${note.score}/10`) : null)), /* @__PURE__ */ React.createElement("div", { className: "mt-5 space-y-4" }, note.settings ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-black/25 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400" }, "Generated in"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-200" }, note.settings)) : null, note.notes ? /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400" }, "Overall notes"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-200" }, note.notes)) : null, note.best_for ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-sky-300/15 bg-sky-300/10 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100" }, "Best for"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-100" }, note.best_for)) : null, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, note.strengths ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-emerald-300/15 bg-emerald-300/10 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-100" }, "Strengths"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-100" }, note.strengths)) : null, note.weaknesses ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-amber-300/15 bg-amber-300/10 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, "Weaknesses"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-100" }, note.weaknesses)) : null))); +} +function normalizePromptDocumentation(documentation) { + const source = documentation && typeof documentation === "object" && !Array.isArray(documentation) ? documentation : {}; + const list2 = (key) => (Array.isArray(source[key]) ? source[key] : []).map((item) => String(item || "").trim()).filter(Boolean); + return { + summary: String(source.summary || "").trim(), + best_for: list2("best_for"), + how_to_use: list2("how_to_use"), + required_inputs: list2("required_inputs"), + workflow: list2("workflow"), + tips: list2("tips"), + common_mistakes: list2("common_mistakes"), + data_accuracy_notes: list2("data_accuracy_notes"), + display_notes: String(source.display_notes || "").trim() + }; +} +function PromptDocumentationPanel({ documentation }) { + const hasContent = Boolean( + documentation.summary || documentation.display_notes || documentation.best_for.length || documentation.how_to_use.length || documentation.required_inputs.length || documentation.workflow.length || documentation.tips.length || documentation.common_mistakes.length || documentation.data_accuracy_notes.length + ); + if (!hasContent) return null; + return /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)] md:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "max-w-3xl" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "How to use"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, "Prompt documentation"), documentation.summary ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300 md:text-base" }, documentation.summary) : null), documentation.best_for.length ? /* @__PURE__ */ React.createElement("div", { className: "mt-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Best for"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex flex-wrap gap-2" }, documentation.best_for.map((item) => /* @__PURE__ */ React.createElement("span", { key: item, className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100" }, item)))) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-5 xl:grid-cols-2" }, documentation.how_to_use.length ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-white/10 bg-black/20 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-[#ffd8cd]" }, "How to use"), /* @__PURE__ */ React.createElement("ol", { className: "mt-3 space-y-3 text-sm leading-7 text-slate-200" }, documentation.how_to_use.map((step, index2) => /* @__PURE__ */ React.createElement("li", { key: `${step}-${index2}`, className: "flex gap-3" }, /* @__PURE__ */ React.createElement("span", { className: "mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-[11px] font-semibold text-white" }, index2 + 1), /* @__PURE__ */ React.createElement("span", null, step))))) : null, documentation.workflow.length ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-white/10 bg-black/20 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-emerald-200/75" }, "Workflow"), /* @__PURE__ */ React.createElement("ol", { className: "mt-3 space-y-3 text-sm leading-7 text-slate-200" }, documentation.workflow.map((step, index2) => /* @__PURE__ */ React.createElement("li", { key: `${step}-${index2}`, className: "flex gap-3" }, /* @__PURE__ */ React.createElement("span", { className: "mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-emerald-300/15 bg-emerald-300/10 text-[11px] font-semibold text-emerald-100" }, index2 + 1), /* @__PURE__ */ React.createElement("span", null, step))))) : null), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-5 xl:grid-cols-3" }, documentation.required_inputs.length ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-white/10 bg-black/20 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400" }, "Required inputs"), /* @__PURE__ */ React.createElement("ul", { className: "mt-3 space-y-2 text-sm leading-7 text-slate-200" }, documentation.required_inputs.map((item) => /* @__PURE__ */ React.createElement("li", { key: item }, item)))) : null, documentation.tips.length ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-emerald-300/15 bg-emerald-300/10 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-emerald-100" }, "Tips"), /* @__PURE__ */ React.createElement("ul", { className: "mt-3 space-y-2 text-sm leading-7 text-slate-100" }, documentation.tips.map((item) => /* @__PURE__ */ React.createElement("li", { key: item }, item)))) : null, documentation.common_mistakes.length ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-amber-300/15 bg-amber-300/10 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100" }, "Common mistakes"), /* @__PURE__ */ React.createElement("ul", { className: "mt-3 space-y-2 text-sm leading-7 text-slate-100" }, documentation.common_mistakes.map((item) => /* @__PURE__ */ React.createElement("li", { key: item }, item)))) : null), documentation.data_accuracy_notes.length ? /* @__PURE__ */ React.createElement("div", { className: "mt-5 rounded-[26px] border border-sky-300/15 bg-sky-300/10 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-100" }, "Data accuracy notes"), /* @__PURE__ */ React.createElement("ul", { className: "mt-3 space-y-2 text-sm leading-7 text-slate-100" }, documentation.data_accuracy_notes.map((item) => /* @__PURE__ */ React.createElement("li", { key: item }, item)))) : null, documentation.display_notes ? /* @__PURE__ */ React.createElement("div", { className: "mt-5 rounded-[26px] border border-[#ffcfbf]/18 bg-[#ffcfbf]/10 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-[#fff0ea]" }, "Display note"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-100" }, documentation.display_notes)) : null); +} +function PromptPlaceholderCard({ placeholder }) { + if (!placeholder || typeof placeholder !== "object") return null; + const example = placeholder.example; + const defaultValue = placeholder.default; + const renderValue = (value) => { + if (value == null || value === "") return null; + if (typeof value === "object") { + return /* @__PURE__ */ React.createElement("pre", { className: "mt-2 overflow-x-auto rounded-[20px] border border-white/10 bg-slate-950/70 p-3 text-xs leading-6 text-slate-200" }, JSON.stringify(value, null, 2)); + } + return /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-200" }, String(value)); + }; + return /* @__PURE__ */ React.createElement("article", { className: "rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(15,23,42,0.2))] p-5 shadow-[0_16px_40px_rgba(2,6,23,0.16)]" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Placeholder"), /* @__PURE__ */ React.createElement("code", { className: "mt-3 inline-flex rounded-full border border-white/10 bg-black/25 px-3 py-1.5 font-mono text-sm text-white" }, "[", placeholder.key || "VALUE", "]"), placeholder.label ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-lg font-semibold tracking-[-0.03em] text-white" }, placeholder.label) : null), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, placeholder.type ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, placeholder.type) : null, placeholder.required ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, "Required") : null)), placeholder.description ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm leading-7 text-slate-300" }, placeholder.description) : null, example != null && example !== "" ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 rounded-[22px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400" }, "Example"), renderValue(example)) : null, defaultValue != null && defaultValue !== "" ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 rounded-[22px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400" }, "Default"), renderValue(defaultValue)) : null); +} +function PromptVariantCard({ variant, analytics, contentId }) { + if (!variant || typeof variant !== "object") return null; + return /* @__PURE__ */ React.createElement("article", { className: `rounded-[28px] border p-5 shadow-[0_16px_40px_rgba(2,6,23,0.16)] ${variant.recommended ? "border-[#ffcfbf]/22 bg-[linear-gradient(180deg,rgba(255,207,191,0.08),rgba(15,23,42,0.24))]" : "border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(15,23,42,0.2))]"}` }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Prompt variant"), /* @__PURE__ */ React.createElement("h3", { className: "mt-2 text-xl font-semibold tracking-[-0.03em] text-white" }, variant.title || "Variant"), variant.description ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-2xl text-sm leading-7 text-slate-300" }, variant.description) : null), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, variant.recommended ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-[#ffcfbf]/22 bg-[#ffcfbf]/12 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[#fff0ea]" }, "Recommended") : null, variant.slug ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, variant.slug) : null)), variant.recommended_for?.length ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, variant.recommended_for.map((item) => /* @__PURE__ */ React.createElement("span", { key: item, className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100" }, item))) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-5 rounded-[24px] border border-white/10 bg-black/25 p-4 md:p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400" }, "Variant prompt"), /* @__PURE__ */ React.createElement(PromptCopyButton, { prompt: variant.prompt, label: "Copy variant", analytics, contentId, eventType: "academy_prompt_variant_copy", metadata: { copy_type: "prompt_variant", variant_title: variant.title || "", source: "prompt_variant" } })), /* @__PURE__ */ React.createElement("pre", { className: "mt-4 whitespace-pre-wrap rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-100" }, variant.prompt)), variant.negative_prompt ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 rounded-[24px] border border-white/10 bg-black/20 p-4 md:p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400" }, "Negative prompt"), /* @__PURE__ */ React.createElement(PromptCopyButton, { prompt: variant.negative_prompt, label: "Copy negative", analytics, contentId, eventType: "academy_prompt_variant_negative_copy", metadata: { copy_type: "prompt_variant_negative", variant_title: variant.title || "", source: "prompt_variant" } })), /* @__PURE__ */ React.createElement("pre", { className: "mt-4 whitespace-pre-wrap rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-200" }, variant.negative_prompt)) : null, variant.risk_notes?.length ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 rounded-[22px] border border-amber-300/15 bg-amber-300/10 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, "Risk notes"), /* @__PURE__ */ React.createElement("ul", { className: "mt-3 space-y-2 text-sm leading-7 text-slate-100" }, variant.risk_notes.map((item) => /* @__PURE__ */ React.createElement("li", { key: item }, item)))) : null); +} +function PromptVariantsSection({ variants, analytics, contentId }) { + const visibleVariants = Array.isArray(variants) ? variants.filter((variant) => variant && typeof variant === "object") : []; + const [activeVariantKey, setActiveVariantKey] = reactExports.useState(""); + reactExports.useEffect(() => { + if (!visibleVariants.length) { + setActiveVariantKey(""); + return; + } + const recommendedVariant = visibleVariants.find((variant) => variant?.recommended); + const nextDefaultKey = String(recommendedVariant?.slug || recommendedVariant?.title || visibleVariants[0]?.slug || visibleVariants[0]?.title || "variant-0"); + setActiveVariantKey((current) => { + if (visibleVariants.some((variant, index2) => String(variant?.slug || variant?.title || `variant-${index2}`) === current)) { + return current; + } + return nextDefaultKey; + }); + }, [visibleVariants]); + if (!visibleVariants.length) return null; + const activeVariant = visibleVariants.find((variant, index2) => String(variant?.slug || variant?.title || `variant-${index2}`) === activeVariantKey) || visibleVariants[0]; + return /* @__PURE__ */ React.createElement("section", { className: "academy-paywalled-content rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-6 text-slate-200 shadow-[0_24px_70px_rgba(2,6,23,0.2)] md:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "max-w-3xl" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Variants"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, "Alternative prompt versions"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, "Switch between safer, shorter, or more specialized prompt variants without losing the core creative direction.")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 overflow-x-auto pb-2" }, /* @__PURE__ */ React.createElement("div", { className: "inline-flex min-w-full gap-3", role: "tablist", "aria-label": "Prompt variants" }, visibleVariants.map((variant, index2) => { + const variantKey = String(variant?.slug || variant?.title || `variant-${index2}`); + const isActive = activeVariant === variant; + return /* @__PURE__ */ React.createElement( + "button", + { + key: variantKey, + type: "button", + role: "tab", + "aria-selected": isActive, + onClick: () => setActiveVariantKey(variantKey), + className: [ + "min-w-[220px] rounded-[24px] border px-4 py-3 text-left transition", + isActive ? "border-sky-300/30 bg-sky-300/12 shadow-[0_16px_40px_rgba(2,6,23,0.18)]" : "border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.05]" + ].join(" ") + }, + /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0" }, /* @__PURE__ */ React.createElement("p", { className: "truncate text-sm font-semibold text-white" }, variant.title || `Variant ${index2 + 1}`), variant.description ? /* @__PURE__ */ React.createElement("p", { className: "mt-1 line-clamp-2 text-xs leading-5 text-slate-300" }, variant.description) : null), variant.recommended ? /* @__PURE__ */ React.createElement("span", { className: "shrink-0 rounded-full border border-[#ffcfbf]/22 bg-[#ffcfbf]/12 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[#fff0ea]" }, "Top pick") : null), + /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex flex-wrap gap-2" }, variant.slug ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, variant.slug) : null, variant.recommended_for?.length ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, variant.recommended_for.length, " use case", variant.recommended_for.length === 1 ? "" : "s") : null) + ); + }))), /* @__PURE__ */ React.createElement("div", { className: "mt-6" }, /* @__PURE__ */ React.createElement(PromptVariantCard, { variant: activeVariant, analytics, contentId }))); +} +function PromptPublicExampleCard({ example, index: index2, galleryIndex, onOpenImage, className = "", frameClassName }) { + if (!example || typeof example !== "object") return null; + const previewUrl = promptInlineImage(example.image_url, example.thumb_url); + if (!previewUrl) return null; + const title = example.title || `Prompt Example ${index2 + 1}`; + const subtitle = [example.provider, example.model_name].filter(Boolean).join(" · "); + const resolvedFrameClassName = frameClassName || (index2 === 0 ? "aspect-[6/5]" : "aspect-[4/5]"); + return /* @__PURE__ */ React.createElement("article", { className: `group overflow-hidden rounded-[24px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(15,23,42,0.3))] shadow-[0_16px_40px_rgba(2,6,23,0.18)] transition hover:-translate-y-0.5 hover:border-sky-300/20 hover:shadow-[0_22px_50px_rgba(2,6,23,0.28)] ${className}` }, /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + onClick: () => onOpenImage?.(galleryIndex), + className: "group block w-full text-left", + "aria-label": `Open example image for ${title}` + }, + /* @__PURE__ */ React.createElement("div", { className: `relative ${resolvedFrameClassName} overflow-hidden bg-slate-950/80` }, /* @__PURE__ */ React.createElement("img", { src: previewUrl, srcSet: example.image_srcset || void 0, sizes: "(max-width: 767px) calc(100vw - 4rem), (max-width: 1279px) calc(50vw - 2rem), 420px", alt: example.alt || title, loading: "lazy", className: "h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" }), /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.02),rgba(2,6,23,0.14)_52%,rgba(2,6,23,0.9))] opacity-0 transition duration-300 group-hover:opacity-100" }), /* @__PURE__ */ React.createElement("div", { className: "absolute inset-x-0 top-0 flex items-start justify-between gap-3 p-3 opacity-0 transition duration-300 group-hover:opacity-100" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-black/35 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-100 backdrop-blur-sm" }, example.type_label || "Variation"), example.score ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/15 px-2.5 py-1 text-[10px] font-semibold text-[#fff0ea] backdrop-blur-sm" }, `${example.score}/10`) : null), /* @__PURE__ */ React.createElement("div", { className: "absolute inset-x-0 bottom-0 translate-y-2 px-3 py-3 opacity-0 transition duration-300 group-hover:translate-y-0 group-hover:opacity-100" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-white" }, title), subtitle ? /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-xs text-slate-300" }, subtitle) : null, example.caption ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 line-clamp-2 text-xs leading-5 text-slate-300/95" }, example.caption) : null)) + )); } function AiComparisonSection({ block }) { const payload = block?.payload || {}; @@ -13158,10 +13994,14 @@ function AiComparisonSection({ block }) { return /* @__PURE__ */ React.createElement("article", { key: result.id || `${result.provider}-${result.model_name}-${result.sort_order || 0}`, className: "overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.04] shadow-[0_16px_40px_rgba(2,6,23,0.18)]" }, /* @__PURE__ */ React.createElement("div", { className: "aspect-video overflow-hidden bg-slate-950/80" }, imageUrl ? /* @__PURE__ */ React.createElement("img", { src: imageUrl, alt: altText, loading: "lazy", className: "h-full w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-full items-center justify-center px-6 text-center text-sm text-slate-500" }, "No comparison image provided.")), /* @__PURE__ */ React.createElement("div", { className: "space-y-4 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h3", { className: "text-xl font-semibold tracking-[-0.03em] text-white" }, result.model_name || result.provider || "AI model"), result.provider ? /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, result.provider) : null), hasScore ? /* @__PURE__ */ React.createElement("div", { className: "rounded-full border border-[#ffb8aa]/20 bg-[#ffb8aa]/10 px-3 py-1 text-sm font-semibold text-[#ffe3dd]" }, `Skinbase score ${score}/10`) : null), result.settings ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[20px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Settings"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-300" }, result.settings)) : null, result.strengths ? /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-200/75" }, "Strengths"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-200" }, result.strengths)) : null, result.weaknesses ? /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-200/75" }, "Weaknesses"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-300" }, result.weaknesses)) : null, result.best_for ? /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/75" }, "Best for"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 whitespace-pre-wrap text-sm leading-7 text-slate-200" }, result.best_for)) : null)); })) : null); } -function AcademyShow({ pageType, item, relatedLessons = [], relatedCourses = [], previousLesson = null, nextLesson = null, seo, pricingUrl, completeUrl, completed: initialCompleted, saveUrl, unsaveUrl, saved: initialSaved, submitUrl, courseContext = null }) { +function AcademyShow({ pageType, item, relatedLessons = [], relatedCourses = [], previousLesson = null, nextLesson = null, seo, pricingUrl, completeUrl, completed: initialCompleted, saveUrl, unsaveUrl, saved: initialSaved, submitUrl, courseContext = null, interaction = null, interactionRoutes = null, loginUrl = null, analytics = null, progressRoutes = null }) { const flash = X$1().props.flash || {}; + useAcademyPageAnalytics(analytics); const [completed, setCompleted] = reactExports.useState(Boolean(initialCompleted)); const [saved, setSaved] = reactExports.useState(Boolean(initialSaved)); + const [liked, setLiked] = reactExports.useState(Boolean(interaction?.liked)); + const [likesCount, setLikesCount] = reactExports.useState(Number(interaction?.likes_count || 0)); + const [savesCount, setSavesCount] = reactExports.useState(Number(interaction?.saves_count || 0)); const [tableOfContents, setTableOfContents] = reactExports.useState([]); const [activeHeadingId, setActiveHeadingId] = reactExports.useState(""); const [lightboxGallery, setLightboxGallery] = reactExports.useState(null); @@ -13181,8 +14021,48 @@ function AcademyShow({ pageType, item, relatedLessons = [], relatedCourses = [], const lessonSummary = item.excerpt || item.description || item.prompt_preview || item.content_preview || "A focused Academy lesson with practical guidance and examples."; const lessonTags = Array.isArray(item?.tags) ? item.tags.filter(Boolean) : []; const promptPreviewImage = item?.preview_image || ""; + const promptPreviewThumbImage = item?.preview_image_thumb || promptPreviewImage; + const promptPreviewSrcSet = item?.preview_image_srcset || ""; const promptBody = item?.prompt || item?.prompt_preview || ""; + const promptDocumentation = normalizePromptDocumentation(item?.documentation); + const promptPlaceholders = Array.isArray(item?.placeholders) ? item.placeholders.filter((placeholder) => placeholder && typeof placeholder === "object" && [ + placeholder.key, + placeholder.label, + placeholder.description, + placeholder.example, + placeholder.default, + placeholder.type + ].some((value) => value != null && value !== "" && value !== false)) : []; + const promptHelperPrompts = Array.isArray(item?.helper_prompts) ? item.helper_prompts.filter((helperPrompt) => helperPrompt && typeof helperPrompt === "object" && [ + helperPrompt.title, + helperPrompt.description, + helperPrompt.prompt, + helperPrompt.expected_output, + helperPrompt.type + ].some(Boolean)) : []; + const promptVariants = Array.isArray(item?.prompt_variants) ? item.prompt_variants.filter((variant) => variant && typeof variant === "object" && [ + variant.title, + variant.description, + variant.prompt, + variant.negative_prompt, + variant.slug, + variant.recommended, + ...Array.isArray(variant.recommended_for) ? variant.recommended_for : [], + ...Array.isArray(variant.risk_notes) ? variant.risk_notes : [] + ].some((value) => value != null && value !== "" && value !== false)) : []; + const promptPublicExamples = Array.isArray(item?.public_examples) ? item.public_examples.filter((example) => example && typeof example === "object" && [ + example.title, + example.caption, + example.image_path, + example.image_url, + example.thumb_path, + example.thumb_url, + example.provider, + example.model_name, + example.score + ].some(Boolean)) : []; const promptComparisons = Array.isArray(item?.tool_notes) ? item.tool_notes.filter((note) => note && typeof note === "object" && note.active !== false && [ + note.display_type, note.provider, note.model_name, note.notes, @@ -13199,7 +14079,20 @@ function AcademyShow({ pageType, item, relatedLessons = [], relatedCourses = [], const promptUsageNotes = String(item?.usage_notes || "").trim(); const promptWorkflowNotes = String(item?.workflow_notes || "").trim(); const promptHasFullAccess = Boolean(item?.prompt); - const promptModelsCovered = promptComparisons.map((note, index2) => note.model_name || note.provider || `Model ${index2 + 1}`); + const hasPromptDocumentation = Boolean( + promptDocumentation.summary || promptDocumentation.display_notes || promptDocumentation.best_for.length || promptDocumentation.how_to_use.length || promptDocumentation.required_inputs.length || promptDocumentation.workflow.length || promptDocumentation.tips.length || promptDocumentation.common_mistakes.length || promptDocumentation.data_accuracy_notes.length + ); + const hasPromptPlaceholders = Boolean(item?.has_placeholder_inputs) && promptPlaceholders.length > 0; + Boolean(item?.has_helper_prompts) && !promptHasFullAccess; + const promptHasLockedVariants = Boolean(item?.has_prompt_variants) && !promptHasFullAccess; + promptHelperPrompts.length > 0; + const hasPromptVariants = promptVariants.length > 0; + const promptAccessRequirement = item?.access_requirement || promptRequirementText(item?.access_level); + const promptUnlockTitle = item?.unlock_heading || promptUnlockHeading(item?.access_level); + const promptUnlockDetails = item?.unlock_description || promptUnlockDescription(item?.access_level); + const promptFeaturedExamples = promptPreviewImage ? promptPublicExamples.slice(0, 2) : promptPublicExamples.slice(0, 4); + const promptOverflowExamples = promptPublicExamples.slice(promptFeaturedExamples.length); + const promptModelsCovered = (promptHasFullAccess && promptComparisons.length ? promptComparisons : promptPublicExamples).map((entry, index2) => entry.model_name || entry.provider || entry.title || `Model ${index2 + 1}`); const promptComparisonGalleryImages = promptComparisons.map((note, index2) => { const src2 = note.image_url || note.thumb_url || ""; if (!src2) return null; @@ -13208,6 +14101,18 @@ function AcademyShow({ pageType, item, relatedLessons = [], relatedCourses = [], alt: note.model_name || note.provider || `Comparison ${index2 + 1}` }; }).filter(Boolean); + const promptPublicExampleGalleryImages = [ + ...promptPreviewImage ? [{ src: promptPreviewImage, alt: item?.title || "Prompt preview" }] : [], + ...promptPublicExamples.map((example, index2) => { + const src2 = example.image_url || example.thumb_url || ""; + if (!src2) return null; + return { + src: src2, + alt: example.alt || example.title || `Prompt example ${index2 + 1}` + }; + }).filter(Boolean) + ]; + const promptBestUseCase = promptComparisons[0]?.best_for || promptDocumentation.best_for[0] || promptUsageNotes || lessonSummary; const academyBreadcrumbs = pageType === "prompt" ? [ { label: "Academy", href: "/academy" }, { label: "Prompt Library", href: "/academy/prompts" }, @@ -13232,14 +14137,66 @@ function AcademyShow({ pageType, item, relatedLessons = [], relatedCourses = [], onSuccess: () => setCompleted(true) }); }; - const toggleSave = () => { + const requireLogin = () => { + if (loginUrl && typeof window !== "undefined") { + window.location.href = loginUrl; + } + }; + const toggleLike = async () => { + if (!interactionRoutes?.like || !analytics?.contentType || !analytics?.contentId) { + return; + } + if (analytics?.isGuest) { + requireLogin(); + return; + } + const payload = await postAcademyAction(interactionRoutes.like, { + content_type: analytics.contentType, + content_id: analytics.contentId + }); + if (payload?.liked !== void 0) { + setLiked(Boolean(payload.liked)); + setLikesCount(Number(payload.likes_count || 0)); + } + }; + const toggleSave = async () => { + if (interactionRoutes?.save && analytics?.contentType && analytics?.contentId) { + if (analytics?.isGuest) { + requireLogin(); + return; + } + const payload = await postAcademyAction(interactionRoutes.save, { + content_type: analytics.contentType, + content_id: analytics.contentId + }); + if (payload?.saved !== void 0) { + setSaved(Boolean(payload.saved)); + setSavesCount(Number(payload.saves_count || 0)); + } + return; + } const url = saved ? unsaveUrl : saveUrl; + if (!url) return; const method = saved ? At.delete : At.post; method(url, {}, { preserveScroll: true, onSuccess: () => setSaved(!saved) }); }; + reactExports.useEffect(() => { + if (pageType !== "lesson" || !progressRoutes?.startLesson || !item?.id || analytics?.isGuest || completed || typeof window === "undefined") { + return; + } + const onceKey = `academy-start-lesson:${item.id}:${courseContext?.id || "solo"}`; + if (window.sessionStorage.getItem(onceKey)) { + return; + } + window.sessionStorage.setItem(onceKey, "1"); + void postAcademyAction(progressRoutes.startLesson, { + lesson_id: item.id, + course_id: courseContext?.id || null + }); + }, [analytics?.isGuest, completed, courseContext?.id, item?.id, pageType, progressRoutes?.startLesson]); const decreaseFontSize = () => { setLessonFontScale((current) => Math.max(fontScaleMin, Number((current - fontScaleStep).toFixed(2)))); }; @@ -13260,6 +14217,13 @@ function AcademyShow({ pageType, item, relatedLessons = [], relatedCourses = [], index: Math.max(0, Math.min(promptComparisonGalleryImages.length - 1, Number(index2 || 0))) }); }; + const openPromptExampleGallery = (index2) => { + if (!promptPublicExampleGalleryImages.length) return; + setLightboxGallery({ + images: promptPublicExampleGalleryImages, + index: Math.max(0, Math.min(promptPublicExampleGalleryImages.length - 1, Number(index2 || 0))) + }); + }; const navigateLightboxGallery = (direction) => { setLightboxGallery((current) => { if (!current?.images?.length) return current; @@ -13459,7 +14423,7 @@ function AcademyShow({ pageType, item, relatedLessons = [], relatedCourses = [], pre.dataset.academyCopyButtonMounted = "true"; }); }, [item?.content, lessonFontScale, pageType]); - return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(59,130,246,0.14),_transparent_26%),linear-gradient(180deg,_#0b1220_0%,_#111827_46%,_#0f172a_100%)] px-4 py-8 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { seo: seo || {}, title: item?.title, description: item?.excerpt || item?.description }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-[1320px] space-y-6" }, flash.success ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100" }, flash.success) : null, flash.error ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100" }, flash.error) : null, item.locked ? /* @__PURE__ */ React.createElement(LockedPanel, { pricingUrl, label: pageType }) : null, pageType === "lesson" ? /* @__PURE__ */ React.createElement("div", { className: "space-y-8" }, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[40px] border border-white/10 bg-black/20 shadow-[0_24px_90px_rgba(15,23,42,0.34)]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-0 lg:grid-cols-[minmax(0,1fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "relative overflow-hidden p-8 md:p-10 lg:p-12" }, lessonCover ? /* @__PURE__ */ React.createElement("img", { src: lessonCover, alt: "", "aria-hidden": "true", className: "absolute inset-0 h-full w-full object-cover opacity-15" }) : null, /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.22),_transparent_34%),linear-gradient(135deg,_rgba(2,6,23,0.96),_rgba(15,23,42,0.78))]" }), /* @__PURE__ */ React.createElement("div", { className: "relative z-10 max-w-3xl" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-100" }, "Skinbase AI Academy"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300" }, lessonCategory), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300" }, lessonDifficulty)), item.lesson_label ? /* @__PURE__ */ React.createElement("p", { className: "mt-5 text-sm font-semibold uppercase tracking-[0.24em] text-amber-100" }, item.lesson_label) : null, /* @__PURE__ */ React.createElement("h1", { className: "mt-5 text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl lg:text-6xl" }, item.title), /* @__PURE__ */ React.createElement("p", { className: "mt-5 max-w-2xl text-base leading-8 text-slate-300 md:text-lg" }, lessonSummary), lessonTags.length ? /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-2" }, lessonTags.map((tag) => /* @__PURE__ */ React.createElement("span", { key: tag, className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200" }, tag))) : null, courseContext?.title ? /* @__PURE__ */ React.createElement("div", { className: "mt-6 max-w-2xl rounded-[24px] border border-white/10 bg-black/25 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Part of course"), /* @__PURE__ */ React.createElement(xe, { href: courseContext.showUrl, className: "mt-2 inline-flex text-lg font-semibold text-sky-100 transition hover:text-white" }, courseContext.title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-7 text-slate-300" }, courseContext.subtitle || "This lesson is being viewed inside a structured Academy course path.")) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-7 flex flex-wrap gap-3" }, completeUrl ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: markComplete, className: "rounded-full border border-emerald-300/25 bg-emerald-300/12 px-5 py-3 text-sm font-semibold text-emerald-100" }, completed ? "Completed" : "Mark complete") : null, saveUrl ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: toggleSave, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, saved ? "Saved" : "Save prompt") : null, submitUrl ? /* @__PURE__ */ React.createElement(xe, { href: submitUrl, className: "rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white transition hover:border-sky-300/25 hover:bg-sky-300/12 hover:text-sky-100" }, "Submit artwork") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement(StatPill$2, { label: "Category", value: lessonCategory }), /* @__PURE__ */ React.createElement(StatPill$2, { label: "Reading", value: lessonMinutes }), /* @__PURE__ */ React.createElement(StatPill$2, { label: "Updated", value: lessonUpdated }), /* @__PURE__ */ React.createElement(StatPill$2, { label: courseContext?.title ? "Course progress" : "Access", value: courseContext?.progress ? `${courseContext.progress.percent}%` : item.access_level || "free" })))), /* @__PURE__ */ React.createElement("aside", { className: "border-t border-white/10 bg-white/[0.03] p-6 lg:border-l lg:border-t-0 lg:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-5 lg:sticky lg:top-6" }, /* @__PURE__ */ React.createElement("div", { className: "overflow-hidden rounded-[28px] border border-white/10 bg-black/20" }, lessonCover ? /* @__PURE__ */ React.createElement("img", { src: lessonCover, alt: item.title, className: "h-52 w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-52 items-center justify-center bg-[linear-gradient(135deg,_rgba(14,165,233,0.18),_rgba(17,24,39,0.94))] text-sm font-semibold uppercase tracking-[0.24em] text-slate-300" }, "Lesson cover")), /* @__PURE__ */ React.createElement("div", { className: "space-y-3" }, /* @__PURE__ */ React.createElement(LessonInfoRow, { label: "Series", value: lessonSeries }), item.formatted_lesson_number ? /* @__PURE__ */ React.createElement(LessonInfoRow, { label: "Lesson", value: item.formatted_lesson_number }) : null, /* @__PURE__ */ React.createElement(LessonInfoRow, { label: "Difficulty", value: lessonDifficulty }), /* @__PURE__ */ React.createElement(LessonInfoRow, { label: "Reading time", value: lessonMinutes }), /* @__PURE__ */ React.createElement(LessonInfoRow, { label: "Published", value: lessonUpdated })), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-black/20 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Lesson status"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, item.locked ? "This lesson is partially locked for your account level." : courseContext?.title ? "This lesson is being tracked inside a course. Completion updates your course progress." : "Full lesson content is available below.")))))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px]" }, /* @__PURE__ */ React.createElement("article", { className: "rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-6 text-slate-200 shadow-[0_24px_70px_rgba(2,6,23,0.2)] md:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-4 border-b border-white/10 pb-5" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80" }, "Article"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, "Lesson content")), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-slate-300" }, lessonMinutes), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-1 rounded-full border border-white/10 bg-black/20 p-1" }, /* @__PURE__ */ React.createElement( + return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(59,130,246,0.14),_transparent_26%),linear-gradient(180deg,_#0b1220_0%,_#111827_46%,_#0f172a_100%)] px-4 py-8 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { seo: seo || {}, title: item?.title, description: item?.excerpt || item?.description }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-[1320px] space-y-6" }, flash.success ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100" }, flash.success) : null, flash.error ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100" }, flash.error) : null, item.locked ? /* @__PURE__ */ React.createElement(LockedPanel, { pricingUrl, label: pageType, accessLevel: item?.access_level, onUpgrade: () => trackUpgradeClick(analytics, { source: `${pageType}_locked_panel` }) }) : null, pageType === "lesson" ? /* @__PURE__ */ React.createElement("div", { className: "space-y-8" }, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[40px] border border-white/10 bg-black/20 shadow-[0_24px_90px_rgba(15,23,42,0.34)]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-0 lg:grid-cols-[minmax(0,1fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "relative overflow-hidden p-8 md:p-10 lg:p-12" }, lessonCover ? /* @__PURE__ */ React.createElement("img", { src: lessonCover, alt: "", "aria-hidden": "true", className: "absolute inset-0 h-full w-full object-cover opacity-15" }) : null, /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.22),_transparent_34%),linear-gradient(135deg,_rgba(2,6,23,0.96),_rgba(15,23,42,0.78))]" }), /* @__PURE__ */ React.createElement("div", { className: "relative z-10 max-w-3xl" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-100" }, "Skinbase AI Academy"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300" }, lessonCategory), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300" }, lessonDifficulty)), item.lesson_label ? /* @__PURE__ */ React.createElement("p", { className: "mt-5 text-sm font-semibold uppercase tracking-[0.24em] text-amber-100" }, item.lesson_label) : null, /* @__PURE__ */ React.createElement("h1", { className: "mt-5 text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl lg:text-6xl" }, item.title), /* @__PURE__ */ React.createElement("p", { className: "mt-5 max-w-2xl text-base leading-8 text-slate-300 md:text-lg" }, lessonSummary), lessonTags.length ? /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-2" }, lessonTags.map((tag) => /* @__PURE__ */ React.createElement("span", { key: tag, className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200" }, tag))) : null, courseContext?.title ? /* @__PURE__ */ React.createElement("div", { className: "mt-6 max-w-2xl rounded-[24px] border border-white/10 bg-black/25 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Part of course"), /* @__PURE__ */ React.createElement(xe, { href: courseContext.showUrl, className: "mt-2 inline-flex text-lg font-semibold text-sky-100 transition hover:text-white" }, courseContext.title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-7 text-slate-300" }, courseContext.subtitle || "This lesson is being viewed inside a structured Academy course path.")) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-7 flex flex-wrap gap-3" }, completeUrl ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: markComplete, className: "rounded-full border border-emerald-300/25 bg-emerald-300/12 px-5 py-3 text-sm font-semibold text-emerald-100" }, completed ? "Completed" : "Mark complete") : null, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: toggleLike, className: "rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]" }, liked ? `Liked · ${likesCount}` : `Like · ${likesCount}`), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: toggleSave, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, saved ? `Saved · ${savesCount}` : `Save · ${savesCount}`), submitUrl ? /* @__PURE__ */ React.createElement(xe, { href: submitUrl, className: "rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white transition hover:border-sky-300/25 hover:bg-sky-300/12 hover:text-sky-100" }, "Submit artwork") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement(StatPill$2, { label: "Category", value: lessonCategory }), /* @__PURE__ */ React.createElement(StatPill$2, { label: "Reading", value: lessonMinutes }), /* @__PURE__ */ React.createElement(StatPill$2, { label: "Updated", value: lessonUpdated }), /* @__PURE__ */ React.createElement(StatPill$2, { label: courseContext?.title ? "Course progress" : "Access", value: courseContext?.progress ? `${courseContext.progress.percent}%` : item.access_level || "free" })))), /* @__PURE__ */ React.createElement("aside", { className: "border-t border-white/10 bg-white/[0.03] p-6 lg:border-l lg:border-t-0 lg:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-5 lg:sticky lg:top-6" }, /* @__PURE__ */ React.createElement("div", { className: "overflow-hidden rounded-[28px] border border-white/10 bg-black/20" }, lessonCover ? /* @__PURE__ */ React.createElement("img", { src: lessonCover, alt: item.title, className: "h-52 w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-52 items-center justify-center bg-[linear-gradient(135deg,_rgba(14,165,233,0.18),_rgba(17,24,39,0.94))] text-sm font-semibold uppercase tracking-[0.24em] text-slate-300" }, "Lesson cover")), /* @__PURE__ */ React.createElement("div", { className: "space-y-3" }, /* @__PURE__ */ React.createElement(LessonInfoRow, { label: "Series", value: lessonSeries }), item.formatted_lesson_number ? /* @__PURE__ */ React.createElement(LessonInfoRow, { label: "Lesson", value: item.formatted_lesson_number }) : null, /* @__PURE__ */ React.createElement(LessonInfoRow, { label: "Difficulty", value: lessonDifficulty }), /* @__PURE__ */ React.createElement(LessonInfoRow, { label: "Reading time", value: lessonMinutes }), /* @__PURE__ */ React.createElement(LessonInfoRow, { label: "Published", value: lessonUpdated })), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-black/20 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Lesson status"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, item.locked ? "This lesson is partially locked for your account level." : courseContext?.title ? "This lesson is being tracked inside a course. Completion updates your course progress." : "Full lesson content is available below.")))))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px]" }, /* @__PURE__ */ React.createElement("article", { className: "rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-6 text-slate-200 shadow-[0_24px_70px_rgba(2,6,23,0.2)] md:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-4 border-b border-white/10 pb-5" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80" }, "Article"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, "Lesson content")), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-slate-300" }, lessonMinutes), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-1 rounded-full border border-white/10 bg-black/20 p-1" }, /* @__PURE__ */ React.createElement( "button", { type: "button", @@ -13510,19 +14474,106 @@ function AcademyShow({ pageType, item, relatedLessons = [], relatedCourses = [], }, /* @__PURE__ */ React.createElement("div", { className: "flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-sky-300/15 bg-sky-300/10 text-sm font-semibold text-sky-100" }, String(index2 + 1).padStart(2, "0")), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, relatedLesson.formatted_lesson_number ? /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, relatedLesson.formatted_lesson_number) : null, /* @__PURE__ */ React.createElement("h4", { className: "text-sm font-semibold text-white transition group-hover:text-sky-100" }, relatedLesson.title)), /* @__PURE__ */ React.createElement("span", { className: "shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400" }, formatLessonMinutes(relatedLesson.reading_minutes))), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs leading-6 text-slate-400" }, relatedLesson.excerpt || relatedLesson.content_preview || "Continue the series with the next lesson.")) - )))) : null, relatedCourseList.length ? /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Related courses"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, relatedCourseList.map((course) => /* @__PURE__ */ React.createElement(xe, { key: course.id, href: course.public_url, className: "block rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-sky-300/25 hover:bg-white/[0.06]" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, course.difficulty, " · ", course.access_level), /* @__PURE__ */ React.createElement("h4", { className: "mt-2 text-sm font-semibold text-white" }, course.title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs leading-6 text-slate-400" }, course.excerpt || course.description || "Open this course to continue with a guided path."))))) : null))) : pageType === "prompt" ? /* @__PURE__ */ React.createElement("div", { className: "space-y-8" }, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(4,10,20,0.98),rgba(15,23,42,0.9))] shadow-[0_24px_90px_rgba(15,23,42,0.34)]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-0 lg:grid-cols-[minmax(420px,0.92fr)_minmax(0,1.08fr)]" }, /* @__PURE__ */ React.createElement("div", { className: "relative border-b border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_34%),radial-gradient(circle_at_bottom_right,rgba(255,183,139,0.18),transparent_32%),linear-gradient(180deg,rgba(5,10,20,0.98),rgba(10,17,30,0.94))] p-6 md:p-8 lg:min-h-[760px] lg:border-b-0 lg:border-r lg:border-white/10 lg:p-10" }, /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,rgba(125,211,252,0.12),transparent_24%),radial-gradient(circle_at_80%_75%,rgba(255,207,191,0.12),transparent_28%)]" }), /* @__PURE__ */ React.createElement("div", { className: "relative flex h-full flex-col" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]" }, "Preview artwork"), promptPreviewImage ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, "Click to zoom") : null), /* @__PURE__ */ React.createElement( + )))) : null, relatedCourseList.length ? /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Related courses"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, relatedCourseList.map((course) => /* @__PURE__ */ React.createElement(xe, { key: course.id, href: course.public_url, className: "block rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-sky-300/25 hover:bg-white/[0.06]" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, course.difficulty, " · ", course.access_level), /* @__PURE__ */ React.createElement("h4", { className: "mt-2 text-sm font-semibold text-white" }, course.title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs leading-6 text-slate-400" }, course.excerpt || course.description || "Open this course to continue with a guided path."))))) : null))) : pageType === "prompt" ? /* @__PURE__ */ React.createElement("div", { className: "space-y-8" }, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(4,10,20,0.98),rgba(15,23,42,0.9))] shadow-[0_24px_90px_rgba(15,23,42,0.34)]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-0 lg:grid-cols-[minmax(340px,0.8fr)_minmax(0,1.2fr)]" }, /* @__PURE__ */ React.createElement("div", { className: "relative border-b border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_34%),radial-gradient(circle_at_bottom_right,rgba(255,183,139,0.18),transparent_32%),linear-gradient(180deg,rgba(5,10,20,0.98),rgba(10,17,30,0.94))] p-5 md:p-6 lg:min-h-[660px] lg:border-b-0 lg:border-r lg:border-white/10 lg:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,rgba(125,211,252,0.12),transparent_24%),radial-gradient(circle_at_80%_75%,rgba(255,207,191,0.12),transparent_28%)]" }), /* @__PURE__ */ React.createElement("div", { className: "relative flex h-full flex-col" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]" }, "Preview artwork"), promptPreviewImage ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, "Click to zoom") : null), /* @__PURE__ */ React.createElement( "button", { type: "button", onClick: openPromptPreviewImage, - className: "group mt-4 flex-1 overflow-hidden rounded-[32px] border border-white/10 bg-black/30 text-left shadow-[0_24px_80px_rgba(2,6,23,0.26)] transition hover:border-sky-300/25 focus:outline-none focus:ring-2 focus:ring-sky-300/35", + className: "group mt-3 flex-1 overflow-hidden rounded-[32px] border border-white/10 bg-black/30 text-left shadow-[0_24px_80px_rgba(2,6,23,0.26)] transition hover:border-sky-300/25 focus:outline-none focus:ring-2 focus:ring-sky-300/35", disabled: !promptPreviewImage, "aria-label": promptPreviewImage ? `Open preview image for ${item.title}` : "Preview image unavailable" }, - promptPreviewImage ? /* @__PURE__ */ React.createElement("div", { className: "relative h-full min-h-[360px] overflow-hidden lg:min-h-[620px]" }, /* @__PURE__ */ React.createElement("img", { src: promptPreviewImage, alt: item.title, className: "h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]" }), /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.02),rgba(2,6,23,0.28))]" }), /* @__PURE__ */ React.createElement("div", { className: "absolute bottom-4 left-4 right-4 flex items-end justify-between gap-4 rounded-[24px] border border-white/10 bg-black/25 px-4 py-3 backdrop-blur-md" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.22em] text-sky-100/80" }, "Prompt visual"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm font-semibold text-white" }, "Open full-size preview")), /* @__PURE__ */ React.createElement("span", { className: "inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/10 text-white" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-expand" })))) : /* @__PURE__ */ React.createElement("div", { className: "flex h-full min-h-[360px] items-center justify-center bg-[linear-gradient(135deg,rgba(251,146,60,0.14),rgba(17,24,39,0.96))] px-8 text-center lg:min-h-[620px]" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Visual placeholder"), /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-lg font-semibold text-white" }, "Preview image coming soon"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, "This prompt page will feel much better once the generated cover image is attached."))) - ))), /* @__PURE__ */ React.createElement("div", { className: "relative overflow-hidden p-8 md:p-10 lg:p-12" }, /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(255,183,139,0.14),_transparent_28%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.12),_transparent_28%)]" }), /* @__PURE__ */ React.createElement("div", { className: "relative z-10 max-w-4xl" }, academyBreadcrumbs.length ? /* @__PURE__ */ React.createElement("div", { className: "mb-6" }, /* @__PURE__ */ React.createElement(AcademyBreadcrumbs, { items: academyBreadcrumbs })) : null, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[#fff0ea]" }, "Skinbase AI Academy"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300" }, lessonCategory), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300" }, lessonDifficulty), item.aspect_ratio ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300" }, item.aspect_ratio) : null, item.prompt_of_week ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/25 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-amber-100" }, "Prompt of the week") : null, item.featured ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-100" }, "Featured") : null), /* @__PURE__ */ React.createElement("p", { className: "mt-8 text-sm font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]" }, "Prompt template"), /* @__PURE__ */ React.createElement("h1", { className: "mt-4 max-w-4xl text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl xl:text-6xl" }, item.title), /* @__PURE__ */ React.createElement("p", { className: "mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg" }, lessonSummary), /* @__PURE__ */ React.createElement("div", { className: "mt-7 flex flex-wrap gap-3" }, saveUrl ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: toggleSave, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, saved ? "Saved" : "Save prompt") : null, promptBody ? /* @__PURE__ */ React.createElement(PromptCopyButton, { prompt: promptBody }) : null, item.negative_prompt ? /* @__PURE__ */ React.createElement(PromptCopyButton, { prompt: item.negative_prompt, label: "Copy negative" }) : null), /* @__PURE__ */ React.createElement("div", { className: "mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement(StatPill$2, { label: "Category", value: lessonCategory }), /* @__PURE__ */ React.createElement(StatPill$2, { label: "Access", value: item.access_level || "free" }), /* @__PURE__ */ React.createElement(StatPill$2, { label: "Difficulty", value: lessonDifficulty }), /* @__PURE__ */ React.createElement(StatPill$2, { label: "Updated", value: lessonUpdated })), lessonTags.length ? /* @__PURE__ */ React.createElement("div", { className: "mt-8 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Microtags"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, lessonTags.map((tag) => /* @__PURE__ */ React.createElement("span", { key: tag, className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100" }, tag)))) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-8 grid gap-4 xl:grid-cols-[minmax(0,1.05fr)_minmax(280px,0.95fr)]" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-black/20 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Prompt status"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, item.locked ? "This page shows the prompt summary, but the full prompt text and editor notes stay locked until your Academy access level matches the template." : "This template includes the main prompt, reuse guidance, and model-specific comparison notes in one place.")), promptModelsCovered.length ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-[#ffcfbf]/12 bg-[linear-gradient(180deg,rgba(255,207,191,0.08),rgba(255,255,255,0.03))] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]" }, "Compared with"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-300" }, promptModelsCovered.length, " model", promptModelsCovered.length > 1 ? "s" : "", " documented for this prompt.")), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold text-white" }, promptModelsCovered.length)), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, promptModelsCovered.map((model) => /* @__PURE__ */ React.createElement("span", { key: model, className: "rounded-full border border-[#ffcfbf]/15 bg-[#ffcfbf]/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-[#fff0ea]" }, model)))) : null))))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-8" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-6 text-slate-200 shadow-[0_24px_70px_rgba(2,6,23,0.2)] md:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-4 border-b border-white/10 pb-5" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]" }, "Prompt body"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, "Prompt text and exclusions"))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 space-y-6" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-[#ffcfbf]/15 bg-[linear-gradient(180deg,rgba(255,207,191,0.08),rgba(255,255,255,0.03))] p-5 md:p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-[#fff0ea]" }, promptHasFullAccess ? "Full prompt" : "Preview prompt"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs uppercase tracking-[0.16em] text-slate-500" }, promptHasFullAccess ? "Ready to paste into your generation workflow." : "Upgrade your Academy access to reveal the complete prompt text."))), /* @__PURE__ */ React.createElement("pre", { className: "mt-4 whitespace-pre-wrap rounded-[24px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-7 text-slate-100 md:p-5" }, promptBody || "Prompt text is not available yet.")), item.negative_prompt ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-black/20 p-5 md:p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Negative prompt"), /* @__PURE__ */ React.createElement("pre", { className: "mt-4 whitespace-pre-wrap rounded-[24px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-200 md:p-5" }, item.negative_prompt)) : null)), promptUsageNotes || promptWorkflowNotes ? /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)] md:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-end justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Prompt guidance"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, "How to use this prompt")), !promptHasFullAccess ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, "Full notes visible with access") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-5 md:grid-cols-2" }, promptUsageNotes ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-white/10 bg-black/20 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-200/75" }, "Usage notes"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-200" }, promptUsageNotes)) : null, promptWorkflowNotes ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-white/10 bg-black/20 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-emerald-200/75" }, "Workflow notes"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-200" }, promptWorkflowNotes)) : null)) : null, promptComparisons.length ? /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(255,183,139,0.12),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 text-slate-200 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "max-w-3xl" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffcfbf]" }, "AI model comparisons"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white md:text-3xl" }, "How different models respond to the same prompt"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300 md:text-base" }, "Use these notes to decide which provider fits the result you want before you start tuning or post-processing.")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-5 xl:grid-cols-2" }, promptComparisons.map((note, index2) => /* @__PURE__ */ React.createElement(PromptToolNoteCard, { key: `${note.provider || "provider"}-${note.model_name || "model"}-${index2}`, note, index: index2, galleryIndex: index2, onOpenImage: openPromptComparisonGallery })))) : null), /* @__PURE__ */ React.createElement("aside", { className: "space-y-6 lg:sticky lg:top-6 lg:self-start" }, lessonTags.length ? /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Microtags"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, lessonTags.map((tag) => /* @__PURE__ */ React.createElement("span", { key: tag, className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100" }, tag)))) : null, /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Best use case"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, promptComparisons[0]?.best_for || promptUsageNotes || lessonSummary))))) : /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.04] p-6 text-slate-200" }, pageType === "pack" ? /* @__PURE__ */ React.createElement("div", { className: "space-y-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm leading-8 text-slate-200" }, item.description), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, (item.prompts || []).map((prompt) => /* @__PURE__ */ React.createElement("div", { key: prompt.id, className: "rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("h3", { className: "text-lg font-semibold text-white" }, prompt.title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-7 text-slate-300" }, prompt.excerpt || prompt.prompt_preview))))) : null, pageType === "challenge" ? /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Brief"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 whitespace-pre-wrap text-sm leading-8 text-slate-200" }, item.brief || item.description)), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Rules"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 whitespace-pre-wrap text-sm leading-8 text-slate-200" }, item.rules || "No special rules posted yet."))), (item.submissions || []).length ? /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Approved submissions"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-4 md:grid-cols-2" }, item.submissions.map((submission) => /* @__PURE__ */ React.createElement("div", { key: submission.id, className: "rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("h3", { className: "text-lg font-semibold text-white" }, submission.artwork?.title || "Submission"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-400" }, submission.user?.name || "Unknown creator"))))) : null) : null)), /* @__PURE__ */ React.createElement(ImageLightbox, { gallery: lightboxGallery, onClose: () => setLightboxGallery(null), onNavigate: navigateLightboxGallery })); + promptPreviewImage ? /* @__PURE__ */ React.createElement("div", { className: "relative h-full min-h-[320px] overflow-hidden lg:min-h-[540px]" }, /* @__PURE__ */ React.createElement("img", { src: promptPreviewThumbImage, srcSet: promptPreviewSrcSet || void 0, sizes: "(max-width: 1023px) calc(100vw - 3rem), 720px", alt: item.title, className: "h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]" }), /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.02),rgba(2,6,23,0.28))]" }), /* @__PURE__ */ React.createElement("div", { className: "absolute bottom-4 left-4 right-4 flex items-end justify-between gap-4 rounded-[24px] border border-white/10 bg-black/25 px-4 py-3 backdrop-blur-md" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.22em] text-sky-100/80" }, "Prompt visual"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm font-semibold text-white" }, "Open full-size preview")), /* @__PURE__ */ React.createElement("span", { className: "inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/10 text-white" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-expand" })))) : /* @__PURE__ */ React.createElement("div", { className: "flex h-full min-h-[360px] items-center justify-center bg-[linear-gradient(135deg,rgba(251,146,60,0.14),rgba(17,24,39,0.96))] px-8 text-center lg:min-h-[620px]" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Visual placeholder"), /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-lg font-semibold text-white" }, "Preview image coming soon"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, "This prompt page will feel much better once the generated cover image is attached."))) + ))), /* @__PURE__ */ React.createElement("div", { className: "relative overflow-hidden p-6 md:p-8 lg:p-9" }, /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(255,183,139,0.14),_transparent_28%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.12),_transparent_28%)]" }), /* @__PURE__ */ React.createElement("div", { className: "relative z-10 max-w-3xl" }, academyBreadcrumbs.length ? /* @__PURE__ */ React.createElement("div", { className: "mb-5" }, /* @__PURE__ */ React.createElement(AcademyBreadcrumbs, { items: academyBreadcrumbs })) : null, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-[#fff0ea]" }, "Skinbase AI Academy"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-300" }, lessonCategory), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-300" }, lessonDifficulty), item.aspect_ratio ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-300" }, item.aspect_ratio) : null, item.prompt_of_week ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/25 bg-amber-300/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-100" }, "Prompt of the week") : null, item.featured ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-sky-100" }, "Featured") : null), /* @__PURE__ */ React.createElement("p", { className: "mt-6 text-xs font-semibold uppercase tracking-[0.22em] text-[#ffd8cd]" }, "Prompt template"), /* @__PURE__ */ React.createElement("h1", { className: "mt-3 max-w-3xl text-[clamp(2.4rem,4.8vw,4.5rem)] font-semibold leading-[0.95] tracking-[-0.05em] text-white" }, item.title), /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-2xl text-[15px] leading-7 text-slate-300 md:text-base" }, lessonSummary), /* @__PURE__ */ React.createElement("div", { className: "mt-6 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: toggleLike, className: "rounded-full border border-white/10 bg-white/[0.06] px-4 py-2.5 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]" }, liked ? `Liked · ${likesCount}` : `Like · ${likesCount}`), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: toggleSave, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-sm font-semibold text-sky-100" }, saved ? `Saved · ${savesCount}` : `Save · ${savesCount}`), promptHasFullAccess ? /* @__PURE__ */ React.createElement(PromptCopyButton, { prompt: item.prompt, analytics, contentId: item.id, eventType: "academy_prompt_copy", metadata: { copy_type: "main_prompt", source: "prompt_detail" } }) : null, promptHasFullAccess && item.negative_prompt ? /* @__PURE__ */ React.createElement(PromptCopyButton, { prompt: item.negative_prompt, label: "Copy negative", analytics, contentId: item.id, eventType: "academy_prompt_negative_copy", metadata: { copy_type: "negative_prompt", source: "prompt_detail" } }) : null), /* @__PURE__ */ React.createElement("div", { className: "mt-7 grid gap-3 sm:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement( + PromptHeaderStat, + { + label: "Category", + value: lessonCategory, + icon: "fa-layer-group", + accentClassName: "border-sky-300/15 bg-sky-300/10 text-sky-100", + valueClassName: "text-white" + } + ), /* @__PURE__ */ React.createElement( + PromptHeaderStat, + { + label: "Access", + value: formatMetaDisplay(item.access_level || "free"), + icon: normalizePromptAccessLevel(item.access_level) === "pro" ? "fa-crown" : normalizePromptAccessLevel(item.access_level) === "creator" ? "fa-key" : "fa-lock-open", + accentClassName: normalizePromptAccessLevel(item.access_level) === "pro" ? "border-amber-300/20 bg-amber-300/10 text-amber-100" : normalizePromptAccessLevel(item.access_level) === "creator" ? "border-violet-300/20 bg-violet-300/10 text-violet-100" : "border-emerald-300/20 bg-emerald-300/10 text-emerald-100", + valueClassName: normalizePromptAccessLevel(item.access_level) === "pro" ? "text-amber-50" : normalizePromptAccessLevel(item.access_level) === "creator" ? "text-violet-50" : "text-emerald-50" + } + ), /* @__PURE__ */ React.createElement( + PromptHeaderStat, + { + label: "Difficulty", + value: formatMetaDisplay(lessonDifficulty), + icon: String(lessonDifficulty || "").toLowerCase() === "advanced" ? "fa-bolt" : String(lessonDifficulty || "").toLowerCase() === "beginner" ? "fa-seedling" : "fa-compass-drafting", + accentClassName: String(lessonDifficulty || "").toLowerCase() === "advanced" ? "border-rose-300/20 bg-rose-300/10 text-rose-100" : String(lessonDifficulty || "").toLowerCase() === "beginner" ? "border-emerald-300/20 bg-emerald-300/10 text-emerald-100" : "border-sky-300/20 bg-sky-300/10 text-sky-100", + valueClassName: String(lessonDifficulty || "").toLowerCase() === "advanced" ? "text-rose-50" : String(lessonDifficulty || "").toLowerCase() === "beginner" ? "text-emerald-50" : "text-sky-50" + } + ), /* @__PURE__ */ React.createElement( + PromptHeaderStat, + { + label: "Updated", + value: lessonUpdated, + icon: "fa-calendar-days", + accentClassName: "border-white/10 bg-white/[0.05] text-slate-200", + valueClassName: "text-white" + } + )), lessonTags.length ? /* @__PURE__ */ React.createElement("div", { className: "mt-7 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-4 md:p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Microtags"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex flex-wrap gap-2" }, lessonTags.map((tag) => /* @__PURE__ */ React.createElement("span", { key: tag, className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100" }, tag)))) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-7 grid gap-4 xl:grid-cols-[minmax(0,1.08fr)_minmax(260px,0.92fr)]" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-black/20 p-4 md:p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Prompt status"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, item.locked ? `${promptAccessRequirement ? `${promptAccessRequirement} ` : ""}This page shows the prompt summary and public example results, but the reusable prompt system stays locked until your Academy access level matches the template.` : "This template includes the main prompt, reuse guidance, and model-specific comparison notes in one place.")), promptModelsCovered.length ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-[#ffcfbf]/12 bg-[linear-gradient(180deg,rgba(255,207,191,0.08),rgba(255,255,255,0.03))] p-4 md:p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]" }, "Compared with"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-300" }, promptModelsCovered.length, " model", promptModelsCovered.length > 1 ? "s" : "", " documented for this prompt.")), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold text-white" }, promptModelsCovered.length)), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, promptModelsCovered.map((model) => /* @__PURE__ */ React.createElement("span", { key: model, className: "rounded-full border border-[#ffcfbf]/15 bg-[#ffcfbf]/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-[#fff0ea]" }, model)))) : null))))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-8" }, !promptHasFullAccess && (promptPreviewImage || promptPublicExamples.length) ? /* @__PURE__ */ React.createElement("section", { className: "academy-public-examples rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-6 text-slate-200 shadow-[0_24px_70px_rgba(2,6,23,0.2)] md:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-end justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]" }, "Public examples"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, "Example results from this prompt"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-slate-300" }, "Preview the visual direction before unlocking the full prompt.")), item.locked && promptAccessRequirement ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, promptAccessRequirement) : null), /* @__PURE__ */ React.createElement("div", { className: `mt-6 grid gap-4 ${promptPreviewImage ? "xl:grid-cols-[minmax(0,0.98fr)_minmax(0,1.02fr)] xl:items-start" : ""}` }, promptPreviewImage ? /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + onClick: () => openPromptExampleGallery(0), + className: "group overflow-hidden rounded-[28px] border border-white/10 bg-slate-950 text-left shadow-[0_18px_50px_rgba(2,6,23,0.22)] transition hover:border-sky-300/25", + "aria-label": "Open main prompt preview" + }, + /* @__PURE__ */ React.createElement("div", { className: "relative aspect-[4/5] overflow-hidden xl:aspect-[6/5]" }, /* @__PURE__ */ React.createElement("img", { src: promptPreviewThumbImage, srcSet: promptPreviewSrcSet || void 0, sizes: "(max-width: 767px) calc(100vw - 4rem), (max-width: 1279px) calc(100vw - 4rem), 640px", alt: item?.title || "Prompt preview", className: "h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]" }), /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.04),rgba(2,6,23,0.42))]" })), + /* @__PURE__ */ React.createElement("div", { className: "space-y-2 border-t border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.94),rgba(2,6,23,0.88))] p-4 md:p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-[#ffcfbf]" }, "Preview artwork"), /* @__PURE__ */ React.createElement("h3", { className: "text-xl font-semibold tracking-[-0.03em] text-white" }, "Prompt visual"), /* @__PURE__ */ React.createElement("p", { className: "text-sm leading-6 text-slate-300" }, item?.excerpt || "Studio-ready packaging, pose, and finish.")) + ) : null, promptFeaturedExamples.length ? /* @__PURE__ */ React.createElement("div", { className: `grid gap-3 ${promptPreviewImage ? "sm:grid-cols-2 xl:grid-cols-1" : "sm:grid-cols-2 xl:grid-cols-3"}` }, promptFeaturedExamples.map((example, index2) => /* @__PURE__ */ React.createElement( + PromptPublicExampleCard, + { + key: `${example.image_path || example.image_url || "example"}-${index2}`, + example, + index: index2, + galleryIndex: index2 + (promptPreviewImage ? 1 : 0), + onOpenImage: openPromptExampleGallery, + className: !promptPreviewImage && index2 === 0 ? "sm:col-span-2 xl:col-span-2" : "", + frameClassName: promptPreviewImage ? "aspect-[16/10]" : index2 === 0 ? "aspect-[16/10]" : "aspect-[6/7]" + } + ))) : null), promptOverflowExamples.length ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3" }, promptOverflowExamples.map((example, index2) => /* @__PURE__ */ React.createElement( + PromptPublicExampleCard, + { + key: `${example.image_path || example.image_url || "overflow-example"}-${index2}`, + example, + index: index2 + promptFeaturedExamples.length, + galleryIndex: index2 + promptFeaturedExamples.length + (promptPreviewImage ? 1 : 0), + onOpenImage: openPromptExampleGallery, + frameClassName: index2 % 3 === 0 ? "aspect-[6/7]" : index2 % 3 === 1 ? "aspect-square" : "aspect-[5/6]" + } + ))) : null) : null, /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-6 text-slate-200 shadow-[0_24px_70px_rgba(2,6,23,0.2)] md:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-4 border-b border-white/10 pb-5" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]" }, "Prompt body"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, "Prompt text and exclusions"))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 space-y-6" }, /* @__PURE__ */ React.createElement("div", { className: "academy-paywalled-content rounded-[28px] border border-[#ffcfbf]/15 bg-[linear-gradient(180deg,rgba(255,207,191,0.08),rgba(255,255,255,0.03))] p-5 md:p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-[#fff0ea]" }, promptHasFullAccess ? "Full prompt" : "Preview prompt"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs uppercase tracking-[0.16em] text-slate-500" }, promptHasFullAccess ? "Ready to paste into your generation workflow." : "Upgrade your Academy access to reveal the complete prompt text.")), promptBody ? /* @__PURE__ */ React.createElement( + PromptCopyButton, + { + prompt: promptBody, + label: promptHasFullAccess ? "Copy prompt" : "Copy preview", + analytics, + contentId: item.id, + eventType: "academy_prompt_copy", + metadata: { copy_type: promptHasFullAccess ? "main_prompt" : "preview_prompt", source: "prompt_body" } + } + ) : null), /* @__PURE__ */ React.createElement("pre", { className: "mt-4 whitespace-pre-wrap rounded-[24px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-7 text-slate-100 md:p-5" }, promptBody || "Prompt text is not available yet."), !promptHasFullAccess ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 rounded-[24px] border border-amber-300/20 bg-amber-300/10 p-4 text-amber-50" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100/80" }, promptUnlockTitle || "Unlock the full prompt"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-amber-50/90" }, promptUnlockDetails), promptAccessRequirement ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-xs font-semibold uppercase tracking-[0.18em] text-amber-100" }, promptAccessRequirement) : null) : null), item.negative_prompt ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-black/20 p-5 md:p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Negative prompt"), /* @__PURE__ */ React.createElement( + PromptCopyButton, + { + prompt: item.negative_prompt, + label: "Copy negative", + analytics, + contentId: item.id, + eventType: "academy_prompt_negative_copy", + metadata: { copy_type: "negative_prompt", source: "prompt_body" } + } + )), /* @__PURE__ */ React.createElement("pre", { className: "mt-4 whitespace-pre-wrap rounded-[24px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-200 md:p-5" }, item.negative_prompt)) : null)), promptUsageNotes || promptWorkflowNotes ? /* @__PURE__ */ React.createElement("section", { className: "academy-paywalled-content rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)] md:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-end justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Prompt guidance"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, "How to use this prompt")), !promptHasFullAccess ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, "Full notes visible with access") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-5 md:grid-cols-2" }, promptUsageNotes ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-white/10 bg-black/20 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-200/75" }, "Usage notes"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-200" }, promptUsageNotes)) : null, promptWorkflowNotes ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-white/10 bg-black/20 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-emerald-200/75" }, "Workflow notes"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-200" }, promptWorkflowNotes)) : null)) : null, hasPromptDocumentation ? /* @__PURE__ */ React.createElement(PromptDocumentationPanel, { documentation: promptDocumentation }) : null, hasPromptPlaceholders ? /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-6 text-slate-200 shadow-[0_24px_70px_rgba(2,6,23,0.2)] md:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "max-w-3xl" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Data"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, "Placeholders and required inputs"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, "Prepare these variables before using the final prompt so the output stays consistent and reusable.")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-5 xl:grid-cols-2" }, promptPlaceholders.map((placeholder, index2) => /* @__PURE__ */ React.createElement(PromptPlaceholderCard, { key: `${placeholder.key || "placeholder"}-${index2}`, placeholder })))) : null, null, null, hasPromptVariants ? /* @__PURE__ */ React.createElement(PromptVariantsSection, { variants: promptVariants, analytics, contentId: item.id }) : null, promptHasLockedVariants ? /* @__PURE__ */ React.createElement("section", { className: "academy-paywalled-content rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-6 text-slate-200 shadow-[0_24px_70px_rgba(2,6,23,0.2)] md:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "max-w-3xl" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Variants"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, "Alternative prompt versions are included"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, "This prompt includes recommended or model-specific variants, but they stay locked until your Academy access level matches the template.")), /* @__PURE__ */ React.createElement("div", { className: "mt-6" }, /* @__PURE__ */ React.createElement(LockedPanel, { pricingUrl, label: "prompt", accessLevel: item?.access_level, onUpgrade: () => trackUpgradeClick(analytics, { source: "prompt_variant_locked_panel" }) }))) : null, promptComparisons.length ? /* @__PURE__ */ React.createElement("section", { className: "academy-paywalled-content rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(255,183,139,0.12),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 text-slate-200 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "max-w-3xl" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffcfbf]" }, "AI model comparisons"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white md:text-3xl" }, "How different models respond to the same prompt"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300 md:text-base" }, "Use these notes to decide which provider fits the result you want before you start tuning or post-processing.")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-5 xl:grid-cols-2" }, promptComparisons.map((note, index2) => /* @__PURE__ */ React.createElement(PromptToolNoteCard, { key: `${note.provider || "provider"}-${note.model_name || "model"}-${index2}`, note, index: index2, galleryIndex: index2, onOpenImage: openPromptComparisonGallery })))) : null), /* @__PURE__ */ React.createElement("aside", { className: "space-y-6 lg:sticky lg:top-6 lg:self-start" }, lessonTags.length ? /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Microtags"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, lessonTags.map((tag) => /* @__PURE__ */ React.createElement("span", { key: tag, className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100" }, tag)))) : null, /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400" }, "Best use case"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, promptBestUseCase))))) : /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.04] p-6 text-slate-200" }, pageType === "pack" ? /* @__PURE__ */ React.createElement("div", { className: "space-y-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm leading-8 text-slate-200" }, item.description), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, (item.prompts || []).map((prompt) => /* @__PURE__ */ React.createElement("div", { key: prompt.id, className: "rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("h3", { className: "text-lg font-semibold text-white" }, prompt.title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-7 text-slate-300" }, prompt.excerpt || prompt.prompt_preview))))) : null, pageType === "challenge" ? /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Brief"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 whitespace-pre-wrap text-sm leading-8 text-slate-200" }, item.brief || item.description)), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Rules"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 whitespace-pre-wrap text-sm leading-8 text-slate-200" }, item.rules || "No special rules posted yet."))), (item.submissions || []).length ? /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Approved submissions"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-4 md:grid-cols-2" }, item.submissions.map((submission) => /* @__PURE__ */ React.createElement("div", { key: submission.id, className: "rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("h3", { className: "text-lg font-semibold text-white" }, submission.artwork?.title || "Submission"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-400" }, submission.user?.name || "Unknown creator"))))) : null) : null)), /* @__PURE__ */ React.createElement(ImageLightbox, { gallery: lightboxGallery, onClose: () => setLightboxGallery(null), onNavigate: navigateLightboxGallery })); } -const __vite_glob_0_6 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_9 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AcademyShow }, Symbol.toStringTag, { value: "Module" })); @@ -13549,6 +14600,7 @@ const buildAdminNavGroups = (isAdmin) => [ { label: "Stories", href: "/moderation/stories", icon: "fa-solid fa-feather-pointed" }, { label: "Artworks", href: "/moderation/artworks", icon: "fa-solid fa-images" }, { label: "Featured Artworks", href: "/moderation/artworks/featured", icon: "fa-solid fa-star" }, + { label: "Web Stories", href: "/moderation/web-stories", icon: "fa-solid fa-book-open-reader" }, { label: "Homepage Announcements", href: "/moderation/homepage/announcements", icon: "fa-solid fa-bullhorn" }, { label: "Upload Queue", href: "/moderation/uploads", icon: "fa-solid fa-cloud-arrow-up" }, { label: "Username Queue", href: "/moderation/usernames/moderation", icon: "fa-solid fa-id-badge" }, @@ -13559,6 +14611,8 @@ const buildAdminNavGroups = (isAdmin) => [ label: "Academy", items: [ { label: "Academy Dashboard", href: "/moderation/academy/dashboard", icon: "fa-solid fa-graduation-cap" }, + { label: "Academy Billing", href: "/moderation/academy/billing", icon: "fa-solid fa-credit-card" }, + { label: "Academy Analytics", href: "/moderation/academy/analytics", icon: "fa-solid fa-chart-line" }, { label: "Academy Courses", href: "/moderation/academy/courses", icon: "fa-solid fa-road" }, { label: "Academy Lessons", href: "/moderation/academy/lessons", icon: "fa-solid fa-book-open" }, { label: "Academy Prompts", href: "/moderation/academy/prompts", icon: "fa-solid fa-wand-magic-sparkles" }, @@ -13611,6 +14665,186 @@ function AdminLayout({ children, title, subtitle }) { /* @__PURE__ */ React.createElement("i", { className: mobileOpen ? "fa-solid fa-xmark" : "fa-solid fa-bars" }) )), mobileOpen && /* @__PURE__ */ React.createElement("div", { className: "fixed inset-0 z-30 lg:hidden" }, /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-black/60 backdrop-blur-sm", onClick: () => setMobileOpen(false) }), /* @__PURE__ */ React.createElement("div", { className: "absolute left-0 top-0 h-full w-72 pt-14" }, /* @__PURE__ */ React.createElement(Sidebar, { pathname, isAdmin: currentUserIsAdmin }))), /* @__PURE__ */ React.createElement("div", { className: "flex flex-1 flex-col lg:pl-8" }, /* @__PURE__ */ React.createElement("main", { className: "flex-1 px-6 py-8 pt-20 lg:pt-8" }, (title || subtitle) && /* @__PURE__ */ React.createElement("div", { className: "mb-8" }, title && /* @__PURE__ */ React.createElement("h1", { className: "text-2xl font-bold text-white" }, title), subtitle && /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, subtitle)), children))); } +function AnalyticsNav({ items = [] }) { + if (!items.length) return null; + const pathname = typeof window !== "undefined" ? window.location.pathname : ""; + return /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, items.map((item) => { + const active = pathname === item.href; + return /* @__PURE__ */ React.createElement( + xe, + { + key: item.href, + href: item.href, + className: `rounded-full border px-4 py-2 text-sm font-semibold transition ${active ? "border-sky-300/25 bg-sky-300/12 text-sky-100" : "border-white/[0.08] bg-white/[0.04] text-slate-300 hover:border-white/15 hover:bg-white/[0.06] hover:text-white"}` + }, + item.label + ); + })); +} +const __vite_glob_0_13 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ + __proto__: null, + default: AnalyticsNav +}, Symbol.toStringTag, { value: "Module" })); +function MetricCell({ value, suffix = "" }) { + return /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, value, suffix); +} +function AcademyAnalyticsContent({ nav = [], range: range2, title, subtitle, rows = [] }) { + return /* @__PURE__ */ React.createElement(AdminLayout, { title, subtitle }, /* @__PURE__ */ React.createElement(Se$1, { title: `Admin · ${title}` }), /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement(AnalyticsNav, { items: nav }), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Range"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm text-slate-300" }, range2?.from, " to ", range2?.to)), /* @__PURE__ */ React.createElement("div", { className: "overflow-hidden rounded-[28px] border border-white/[0.08] bg-white/[0.03]" }, /* @__PURE__ */ React.createElement("div", { className: "overflow-x-auto" }, /* @__PURE__ */ React.createElement("table", { className: "min-w-full text-left text-sm" }, /* @__PURE__ */ React.createElement("thead", { className: "border-b border-white/[0.08] bg-black/20 text-[11px] uppercase tracking-[0.18em] text-slate-500" }, /* @__PURE__ */ React.createElement("tr", null, /* @__PURE__ */ React.createElement("th", { className: "px-4 py-3" }, "Title"), /* @__PURE__ */ React.createElement("th", { className: "px-4 py-3" }, "Type"), /* @__PURE__ */ React.createElement("th", { className: "px-4 py-3" }, "Access"), /* @__PURE__ */ React.createElement("th", { className: "px-4 py-3" }, "Views"), /* @__PURE__ */ React.createElement("th", { className: "px-4 py-3" }, "Unique"), /* @__PURE__ */ React.createElement("th", { className: "px-4 py-3" }, "Engaged"), /* @__PURE__ */ React.createElement("th", { className: "px-4 py-3" }, "Likes"), /* @__PURE__ */ React.createElement("th", { className: "px-4 py-3" }, "Saves"), /* @__PURE__ */ React.createElement("th", { className: "px-4 py-3" }, "Copies"), /* @__PURE__ */ React.createElement("th", { className: "px-4 py-3" }, "Starts"), /* @__PURE__ */ React.createElement("th", { className: "px-4 py-3" }, "Completions"), /* @__PURE__ */ React.createElement("th", { className: "px-4 py-3" }, "Upgrade Clicks"), /* @__PURE__ */ React.createElement("th", { className: "px-4 py-3" }, "Popularity"), /* @__PURE__ */ React.createElement("th", { className: "px-4 py-3" }, "Trend"))), /* @__PURE__ */ React.createElement("tbody", null, rows.length ? rows.map((row) => /* @__PURE__ */ React.createElement("tr", { key: `${row.content_type}-${row.content_id || "none"}`, className: "border-b border-white/[0.06] align-top text-slate-300" }, /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, row.title), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-xs uppercase tracking-[0.16em] text-slate-500" }, "ID ", row.content_id || "n/a")), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4" }, row.content_type_label), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4" }, row.access_level || "n/a"), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4" }, /* @__PURE__ */ React.createElement(MetricCell, { value: row.views })), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4" }, /* @__PURE__ */ React.createElement(MetricCell, { value: row.unique_visitors })), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4" }, /* @__PURE__ */ React.createElement(MetricCell, { value: row.engaged_views })), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4" }, /* @__PURE__ */ React.createElement(MetricCell, { value: row.likes })), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4" }, /* @__PURE__ */ React.createElement(MetricCell, { value: row.saves })), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4" }, /* @__PURE__ */ React.createElement(MetricCell, { value: row.prompt_copies })), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4" }, /* @__PURE__ */ React.createElement(MetricCell, { value: row.starts })), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4" }, /* @__PURE__ */ React.createElement(MetricCell, { value: row.completions })), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4" }, /* @__PURE__ */ React.createElement(MetricCell, { value: row.upgrade_clicks })), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4" }, /* @__PURE__ */ React.createElement(MetricCell, { value: row.popularity_score })), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4" }, row.trend))) : /* @__PURE__ */ React.createElement("tr", null, /* @__PURE__ */ React.createElement("td", { colSpan: 14, className: "px-4 py-10 text-center text-slate-400" }, "No rollup data available yet for this view.")))))))); +} +const __vite_glob_0_10 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ + __proto__: null, + default: AcademyAnalyticsContent +}, Symbol.toStringTag, { value: "Module" })); +function StatCard$f({ label, value }) { + return /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, label), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-3xl font-bold text-white" }, Number(value || 0).toLocaleString())); +} +function AcademyAnalyticsFunnel({ nav = [], range: range2, summary = {}, bestConverters = [] }) { + return /* @__PURE__ */ React.createElement(AdminLayout, { title: "Academy Funnel", subtitle: "Early conversion signals from premium previews, upgrade clicks, and learning starts." }, /* @__PURE__ */ React.createElement(Se$1, { title: "Admin · Academy Funnel" }), /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement(AnalyticsNav, { items: nav }), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Range"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm text-slate-300" }, range2?.from, " to ", range2?.to)), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 sm:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement(StatCard$f, { label: "Academy Visitors", value: summary.academyVisitors }), /* @__PURE__ */ React.createElement(StatCard$f, { label: "Premium Preview Views", value: summary.premiumPreviewViews }), /* @__PURE__ */ React.createElement(StatCard$f, { label: "Upgrade Clicks", value: summary.upgradeClicks }), /* @__PURE__ */ React.createElement(StatCard$f, { label: "Learning Starts", value: summary.starts }), /* @__PURE__ */ React.createElement(StatCard$f, { label: "Completions", value: summary.completions }), /* @__PURE__ */ React.createElement(StatCard$f, { label: "Checkout Starts", value: summary.checkoutStarts }), /* @__PURE__ */ React.createElement(StatCard$f, { label: "Subscriptions", value: summary.subscriptions })), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Best Converting Content"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, bestConverters.length ? bestConverters.map((item) => /* @__PURE__ */ React.createElement("div", { key: `${item.content_type}-${item.content_id || "none"}`, className: "rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-xs uppercase tracking-[0.18em] text-slate-500" }, item.content_type_label)), /* @__PURE__ */ React.createElement("div", { className: "text-right" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-sky-100" }, item.conversion_score), /* @__PURE__ */ React.createElement("p", { className: "text-xs uppercase tracking-[0.18em] text-slate-500" }, "conversion"))))) : /* @__PURE__ */ React.createElement("p", { className: "rounded-2xl border border-dashed border-white/[0.08] bg-black/20 px-4 py-6 text-sm text-slate-400" }, "No conversion signals have been rolled up yet."))))); +} +const __vite_glob_0_11 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ + __proto__: null, + default: AcademyAnalyticsFunnel +}, Symbol.toStringTag, { value: "Module" })); +function SummaryCard$6({ label, value, description }) { + return /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/[0.08] bg-white/[0.04] p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, label), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-3xl font-bold text-white" }, Number(value || 0).toLocaleString()), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-slate-300" }, description)); +} +function RangeControls({ range: range2 }) { + const pathname = typeof window !== "undefined" ? window.location.pathname : ""; + const [from, setFrom] = reactExports.useState(range2?.from || ""); + const [to, setTo] = reactExports.useState(range2?.to || ""); + const visit2 = (nextRange, nextFrom = from, nextTo = to) => { + At.get(pathname, { + range: nextRange, + ...nextRange === "custom" ? { from: nextFrom, to: nextTo } : {} + }, { + preserveScroll: true, + preserveState: true, + replace: true + }); + }; + return /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Date Range"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm text-slate-300" }, range2?.from, " to ", range2?.to)), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, (range2?.options || []).map((option) => { + const active = option.value === range2?.active; + return /* @__PURE__ */ React.createElement( + "button", + { + key: option.value, + type: "button", + onClick: () => visit2(option.value), + className: `rounded-full border px-4 py-2 text-sm font-semibold transition ${active ? "border-sky-300/30 bg-sky-300/12 text-sky-100" : "border-white/[0.08] bg-white/[0.04] text-slate-300 hover:border-white/15 hover:bg-white/[0.06] hover:text-white"}` + }, + option.label + ); + }))), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap items-end gap-3 border-t border-white/[0.08] pt-5" }, /* @__PURE__ */ React.createElement("label", { className: "flex flex-col gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", null, "From"), /* @__PURE__ */ React.createElement("input", { type: "date", value: from, onChange: (event) => setFrom(event.target.value), className: "rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-3 text-white outline-none transition focus:border-sky-300/30" })), /* @__PURE__ */ React.createElement("label", { className: "flex flex-col gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", null, "To"), /* @__PURE__ */ React.createElement("input", { type: "date", value: to, onChange: (event) => setTo(event.target.value), className: "rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-3 text-white outline-none transition focus:border-sky-300/30" })), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => visit2("custom", from, to), className: "rounded-2xl border border-white/[0.08] bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/15 hover:bg-white/[0.06]" }, "Apply Custom Range"))); +} +function Section$2({ title, description, children }) { + return /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/[0.08] bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, title), /* @__PURE__ */ React.createElement("p", { className: "mt-3 max-w-3xl text-sm leading-6 text-slate-300" }, description))), /* @__PURE__ */ React.createElement("div", { className: "mt-5" }, children)); +} +function EmptyState$7({ text: text2 }) { + return /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-dashed border-white/[0.08] bg-black/20 px-4 py-8 text-sm text-slate-400" }, text2); +} +function Badge$4({ children, tone = "default" }) { + const tones2 = { + default: "border-white/[0.08] bg-white/[0.04] text-slate-200", + high: "border-rose-300/25 bg-rose-300/10 text-rose-100", + medium: "border-amber-300/25 bg-amber-300/10 text-amber-100", + low: "border-emerald-300/25 bg-emerald-300/10 text-emerald-100" + }; + return /* @__PURE__ */ React.createElement("span", { className: `inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${tones2[tone] || tones2.default}` }, children); +} +function Table({ columns, children }) { + return /* @__PURE__ */ React.createElement("div", { className: "overflow-x-auto rounded-[24px] border border-white/[0.08] bg-black/20" }, /* @__PURE__ */ React.createElement("table", { className: "min-w-full divide-y divide-white/[0.08] text-left" }, /* @__PURE__ */ React.createElement("thead", null, /* @__PURE__ */ React.createElement("tr", null, columns.map((column) => /* @__PURE__ */ React.createElement("th", { key: column, className: "px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, column)))), /* @__PURE__ */ React.createElement("tbody", { className: "divide-y divide-white/[0.06]" }, children))); +} +function OpportunityHighlights({ items = [] }) { + if (!items.length) { + return /* @__PURE__ */ React.createElement(EmptyState$7, { text: "Recommendations will appear here once Academy analytics has enough activity in the selected range." }); + } + return /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 xl:grid-cols-2" }, items.map((item, index2) => /* @__PURE__ */ React.createElement("div", { key: `${item.title}-${index2}`, className: "rounded-[24px] border border-white/[0.08] bg-black/20 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-base font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement(Badge$4, { tone: item.priority }, item.priority)), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-slate-300" }, item.reason), /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm font-semibold text-sky-100" }, item.suggested_action)))); +} +function AcademyAnalyticsIntelligence({ + nav = [], + range: range2, + contentOpportunities = {}, + searchGaps = {}, + promptInsights = {}, + lessonDropoffs = {}, + courseHealth = {}, + premiumInterest = {}, + editorialRecommendations = {} +}) { + return /* @__PURE__ */ React.createElement(AdminLayout, { title: "Academy Content Intelligence", subtitle: "Editorial and business signals from Academy rollups, search demand, engagement, and premium intent." }, /* @__PURE__ */ React.createElement(Se$1, { title: "Admin · Academy Content Intelligence" }), /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement(AnalyticsNav, { items: nav }), /* @__PURE__ */ React.createElement(RangeControls, { range: range2 }), /* @__PURE__ */ React.createElement(Section$2, { title: "Content Opportunities", description: "A fast view of where Academy demand is strongest, where content is underperforming, and which changes should be prioritized next." }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-7" }, (contentOpportunities?.cards || []).map((card) => /* @__PURE__ */ React.createElement(SummaryCard$6, { key: card.label, label: card.label, value: card.value, description: card.description }))), /* @__PURE__ */ React.createElement("div", { className: "mt-6" }, /* @__PURE__ */ React.createElement(OpportunityHighlights, { items: contentOpportunities?.highlights || [] }))), /* @__PURE__ */ React.createElement(Section$2, { title: "Search Gaps", description: "Queries that suggest missing content, weak relevance, or topics worth expanding because users are clearly engaging with them." }, searchGaps?.rows?.length ? /* @__PURE__ */ React.createElement(Table, { columns: ["Query", "Searches", "Results", "Clicks", "CTR", "Suggested Action"] }, searchGaps.rows.map((row) => /* @__PURE__ */ React.createElement("tr", { key: row.normalized_query }, /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 align-top" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, row.query), /* @__PURE__ */ React.createElement("div", { className: "mt-2 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement(Badge$4, { tone: row.priority }, row.issue), row.logged_in_searches > 1 ? /* @__PURE__ */ React.createElement(Badge$4, null, "Logged-in x", row.logged_in_searches) : null, row.subscriber_searches > 0 ? /* @__PURE__ */ React.createElement(Badge$4, null, "Subscribers x", row.subscriber_searches) : null)), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.searches), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.results_count), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.clicks), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.ctr, "%"), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm leading-6 text-slate-300" }, row.suggested_action)))) : /* @__PURE__ */ React.createElement(EmptyState$7, { text: "No Academy search gaps were detected in this range." })), /* @__PURE__ */ React.createElement(Section$2, { title: "Prompt Insights", description: "Signals that show whether prompts need better quality, stronger discoverability, more examples, or a premium follow-up." }, promptInsights?.rows?.length ? /* @__PURE__ */ React.createElement(Table, { columns: ["Prompt", "Views", "Copies", "Copy Rate", "Saves", "Likes", "Issue", "Suggested Action"] }, promptInsights.rows.map((row) => /* @__PURE__ */ React.createElement("tr", { key: row.content_id }, /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 align-top" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, row.title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs uppercase tracking-[0.18em] text-slate-500" }, row.content_type_label)), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.views), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.prompt_copies), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.copy_rate, "%"), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.saves), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.likes), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4" }, /* @__PURE__ */ React.createElement(Badge$4, { tone: row.priority }, row.issue)), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm leading-6 text-slate-300" }, row.suggested_action)))) : /* @__PURE__ */ React.createElement(EmptyState$7, { text: "No prompt intelligence signals were detected in this range." })), /* @__PURE__ */ React.createElement(Section$2, { title: "Lesson Drop-offs", description: "Lessons where users hesitate to start, fail to finish, or unexpectedly show strong premium interest." }, lessonDropoffs?.rows?.length ? /* @__PURE__ */ React.createElement(Table, { columns: ["Lesson", "Views", "Starts", "Completions", "Completion Rate", "Issue", "Suggested Action"] }, lessonDropoffs.rows.map((row) => /* @__PURE__ */ React.createElement("tr", { key: row.content_id }, /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 align-top" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, row.title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs uppercase tracking-[0.18em] text-slate-500" }, "Start rate ", row.start_rate, "%")), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.views), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.starts), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.completions), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.completion_rate, "%"), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4" }, /* @__PURE__ */ React.createElement(Badge$4, { tone: row.priority }, row.issue)), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm leading-6 text-slate-300" }, row.suggested_action)))) : /* @__PURE__ */ React.createElement(EmptyState$7, { text: "No lesson drop-off signals were detected in this range." })), /* @__PURE__ */ React.createElement(Section$2, { title: "Course Health", description: "Courses that need better positioning or restructuring, plus courses that have enough momentum to justify expansion." }, courseHealth?.rows?.length ? /* @__PURE__ */ React.createElement(Table, { columns: ["Course", "Views", "Starts", "Completions", "Completion Rate", "Avg Progress", "Suggested Action"] }, courseHealth.rows.map((row) => /* @__PURE__ */ React.createElement("tr", { key: row.content_id }, /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 align-top" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, row.title), /* @__PURE__ */ React.createElement("div", { className: "mt-2 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement(Badge$4, { tone: row.priority }, row.issue), row.learners > 0 ? /* @__PURE__ */ React.createElement(Badge$4, null, "Learners ", row.learners) : null)), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.views), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.starts), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.completions), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.completion_rate, "%"), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.avg_progress, "%"), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm leading-6 text-slate-300" }, row.suggested_action)))) : /* @__PURE__ */ React.createElement(EmptyState$7, { text: "No course health signals were detected in this range." })), /* @__PURE__ */ React.createElement(Section$2, { title: "Premium Interest", description: "Free and premium Academy content that either converts well into upgrade intent or needs stronger teaser positioning." }, premiumInterest?.rows?.length ? /* @__PURE__ */ React.createElement(Table, { columns: ["Content", "Type", "Premium Views", "Upgrade Clicks", "Upgrade Rate", "Suggested Action"] }, premiumInterest.rows.map((row) => /* @__PURE__ */ React.createElement("tr", { key: `${row.content_type}-${row.content_id}` }, /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 align-top" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, row.title), /* @__PURE__ */ React.createElement("div", { className: "mt-2 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement(Badge$4, { tone: row.priority }, row.issue), /* @__PURE__ */ React.createElement(Badge$4, null, "Interest score ", row.premium_interest_score))), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.content_type_label), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.premium_preview_views), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.upgrade_clicks), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm text-slate-200" }, row.upgrade_rate, "%"), /* @__PURE__ */ React.createElement("td", { className: "px-4 py-4 text-sm leading-6 text-slate-300" }, row.suggested_action)))) : /* @__PURE__ */ React.createElement(EmptyState$7, { text: "No premium interest signals were detected in this range." })), /* @__PURE__ */ React.createElement(Section$2, { title: "Editorial Recommendations", description: "Prioritized recommendations that combine content demand, user behavior, and premium intent into concrete next actions." }, editorialRecommendations?.rows?.length ? /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 xl:grid-cols-2" }, editorialRecommendations.rows.map((row, index2) => /* @__PURE__ */ React.createElement("div", { key: `${row.title}-${index2}`, className: "rounded-[24px] border border-white/[0.08] bg-black/20 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-base font-semibold text-white" }, row.title), /* @__PURE__ */ React.createElement(Badge$4, { tone: row.priority }, row.priority)), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-slate-300" }, row.description), /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Reason"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-300" }, row.reason), /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Suggested Action"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold leading-6 text-sky-100" }, row.suggested_action)))) : /* @__PURE__ */ React.createElement(EmptyState$7, { text: "No editorial recommendations were generated for this range yet." })))); +} +const __vite_glob_0_12 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ + __proto__: null, + default: AcademyAnalyticsIntelligence +}, Symbol.toStringTag, { value: "Module" })); +function StatCard$e({ label, value }) { + return /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, label), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-3xl font-bold text-white" }, Number(value || 0).toLocaleString())); +} +function ContentList({ title, items = [] }) { + return /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, title), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, items.length ? items.map((item) => /* @__PURE__ */ React.createElement("div", { key: `${item.content_type}-${item.content_id || "none"}`, className: "rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-xs uppercase tracking-[0.18em] text-slate-500" }, item.content_type_label)), /* @__PURE__ */ React.createElement("div", { className: "text-right" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-sky-100" }, item.popularity_score), /* @__PURE__ */ React.createElement("p", { className: "text-xs uppercase tracking-[0.18em] text-slate-500" }, "popularity"))))) : /* @__PURE__ */ React.createElement("p", { className: "rounded-2xl border border-dashed border-white/[0.08] bg-black/20 px-4 py-6 text-sm text-slate-400" }, "No rollup data yet for this range."))); +} +function AcademyAnalyticsOverview({ nav = [], range: range2, stats, topContent = [], topWeek = [] }) { + return /* @__PURE__ */ React.createElement(AdminLayout, { title: "Academy Analytics", subtitle: "Daily rollup overview for Academy traffic, engagement, and subscription intent." }, /* @__PURE__ */ React.createElement(Se$1, { title: "Admin · Academy Analytics" }), /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement(AnalyticsNav, { items: nav }), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Range"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm text-slate-300" }, range2?.from, " to ", range2?.to)), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 sm:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement(StatCard$e, { label: "Views", value: stats.views }), /* @__PURE__ */ React.createElement(StatCard$e, { label: "Unique Visitors", value: stats.uniqueVisitors }), /* @__PURE__ */ React.createElement(StatCard$e, { label: "Logged-in Views", value: stats.userViews }), /* @__PURE__ */ React.createElement(StatCard$e, { label: "Guest Views", value: stats.guestViews }), /* @__PURE__ */ React.createElement(StatCard$e, { label: "Subscriber Views", value: stats.subscriberViews }), /* @__PURE__ */ React.createElement(StatCard$e, { label: "Prompt Copies", value: stats.promptCopies }), /* @__PURE__ */ React.createElement(StatCard$e, { label: "Likes", value: stats.likes }), /* @__PURE__ */ React.createElement(StatCard$e, { label: "Saves", value: stats.saves }), /* @__PURE__ */ React.createElement(StatCard$e, { label: "Lesson Completions", value: stats.lessonCompletions }), /* @__PURE__ */ React.createElement(StatCard$e, { label: "Course Starts", value: stats.courseStarts }), /* @__PURE__ */ React.createElement(StatCard$e, { label: "Upgrade Clicks", value: stats.upgradeClicks })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 xl:grid-cols-2" }, /* @__PURE__ */ React.createElement(ContentList, { title: "Top Content In Range", items: topContent }), /* @__PURE__ */ React.createElement(ContentList, { title: "Top Content This Week", items: topWeek })))); +} +const __vite_glob_0_14 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ + __proto__: null, + default: AcademyAnalyticsOverview +}, Symbol.toStringTag, { value: "Module" })); +function SearchList({ title, items = [], emptyText }) { + return /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, title), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, items.length ? items.map((item, index2) => /* @__PURE__ */ React.createElement("div", { key: `${item.query}-${index2}`, className: "rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, item.query), "searches" in item ? /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-sky-100" }, item.searches) : null), "avg_results" in item ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-300" }, "Average results: ", item.avg_results) : null, "clicks" in item ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-300" }, "Clicks: ", item.clicks) : null, "click_through_rate" in item ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-300" }, "CTR: ", item.click_through_rate, "%") : null, "results_count" in item ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-300" }, "Results: ", item.results_count) : null)) : /* @__PURE__ */ React.createElement("p", { className: "rounded-2xl border border-dashed border-white/[0.08] bg-black/20 px-4 py-6 text-sm text-slate-400" }, emptyText))); +} +function FilterUsageList({ items = [] }) { + return /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Filter Usage"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, items.length ? items.map((item) => /* @__PURE__ */ React.createElement("div", { key: `${item.filter}-${item.value}`, className: "rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, item.filter, ": ", item.value), /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-sky-100" }, item.uses)))) : /* @__PURE__ */ React.createElement("p", { className: "rounded-2xl border border-dashed border-white/[0.08] bg-black/20 px-4 py-6 text-sm text-slate-400" }, "No Academy search filters were used in this range."))); +} +function ClickedResultsList({ items = [] }) { + return /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Top Clicked Results"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, items.length ? items.map((item) => /* @__PURE__ */ React.createElement("div", { key: `${item.content_type}-${item.content_id}`, className: "rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-sky-100" }, item.clicks)), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-300" }, item.content_type))) : /* @__PURE__ */ React.createElement("p", { className: "rounded-2xl border border-dashed border-white/[0.08] bg-black/20 px-4 py-6 text-sm text-slate-400" }, "No clicked Academy search results were logged in this range."))); +} +function AcademyAnalyticsSearch({ nav = [], range: range2, summary = {}, topSearches = [], zeroResults = [], lowClickThroughSearches = [], highestClickThroughSearches = [], searchesWithResultsNoClicks = [], topClickedResults = [], filterUsage = [], recentSearches = [] }) { + return /* @__PURE__ */ React.createElement(AdminLayout, { title: "Academy Search Analytics", subtitle: "Search demand, zero-result gaps, and recent Academy query activity." }, /* @__PURE__ */ React.createElement(Se$1, { title: "Admin · Academy Search Analytics" }), /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement(AnalyticsNav, { items: nav }), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 sm:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Searches"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-3xl font-bold text-white" }, Number(summary.searches || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Zero Result Searches"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-3xl font-bold text-white" }, Number(summary.zeroResultSearches || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Logged-in Searches"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-3xl font-bold text-white" }, Number(summary.loggedInSearches || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Subscriber Searches"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-3xl font-bold text-white" }, Number(summary.subscriberSearches || 0).toLocaleString()))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 sm:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Searches With Clicks"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-3xl font-bold text-white" }, Number(summary.searchesWithClicks || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Needs CTR Tracking"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-slate-300" }, "Low-click sections use stored search click attribution when present. Queries without clicked-result updates will stay at 0% CTR until that interaction is sent."))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Range"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm text-slate-300" }, range2?.from, " to ", range2?.to)), /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 xl:grid-cols-3" }, /* @__PURE__ */ React.createElement(SearchList, { title: "Top Searches", items: topSearches, emptyText: "No Academy searches were logged in this range." }), /* @__PURE__ */ React.createElement(SearchList, { title: "Highest CTR Searches", items: highestClickThroughSearches, emptyText: "No clicked Academy searches were logged in this range." }), /* @__PURE__ */ React.createElement(SearchList, { title: "Zero-result Searches", items: zeroResults, emptyText: "No zero-result searches were logged in this range." })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 xl:grid-cols-3" }, /* @__PURE__ */ React.createElement(SearchList, { title: "Low Click-through Searches", items: lowClickThroughSearches, emptyText: "No low click-through Academy searches were logged in this range." }), /* @__PURE__ */ React.createElement(SearchList, { title: "Results With No Clicks", items: searchesWithResultsNoClicks, emptyText: "No Academy searches with results but no clicks were logged in this range." }), /* @__PURE__ */ React.createElement(ClickedResultsList, { items: topClickedResults })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 xl:grid-cols-2" }, /* @__PURE__ */ React.createElement(FilterUsageList, { items: filterUsage }), /* @__PURE__ */ React.createElement(SearchList, { title: "Recent Searches", items: recentSearches, emptyText: "No recent Academy searches were logged in this range." })))); +} +const __vite_glob_0_15 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ + __proto__: null, + default: AcademyAnalyticsSearch +}, Symbol.toStringTag, { value: "Module" })); +function StatCard$d({ label, value, hint = null }) { + return /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, label), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-3xl font-bold text-white" }, value.toLocaleString()), hint ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-400" }, hint) : null); +} +function formatTimestamp$1(value) { + if (!value) return "No webhook processed yet"; + try { + return new Intl.DateTimeFormat(void 0, { + dateStyle: "medium", + timeStyle: "short" + }).format(new Date(value)); + } catch { + return value; + } +} +function formatEventSummary(summary) { + const payload = summary && typeof summary === "object" ? summary : {}; + const preferredKeys = [ + "action", + "outcome", + "local_subscription_status", + "status", + "tracked", + "user_resolved" + ]; + const prioritized = preferredKeys.filter((key) => Object.prototype.hasOwnProperty.call(payload, key)).map((key) => [key, payload[key]]); + const priceIds = Array.isArray(payload.price_ids) && payload.price_ids.length ? [["price_ids", payload.price_ids.join(", ")]] : []; + const cacheCleared = typeof payload.cache_cleared === "boolean" ? [["cache_cleared", payload.cache_cleared ? "yes" : "no"]] : []; + const lines = [...prioritized, ...priceIds, ...cacheCleared].filter(([, value]) => value !== null && value !== void 0 && value !== "").slice(0, 4); + return lines.length ? lines.map(([key, value]) => `${key}: ${String(value)}`).join(" · ") : "No summary fields captured"; +} +function AcademyBilling({ summary, planBreakdown, recentEvents, links }) { + const missingPlans = Array.isArray(summary.missing_plan_keys) ? summary.missing_plan_keys : []; + const noData = summary.enabled && (summary.active_subscribers || 0) === 0 && (summary.ended_subscriptions || 0) === 0 && (summary.recent_webhook_count || 0) === 0; + return /* @__PURE__ */ React.createElement(AdminLayout, { title: "Academy Billing", subtitle: "Moderation overview of Academy subscriptions, Stripe webhook sync activity, and plan readiness." }, /* @__PURE__ */ React.createElement(Se$1, { title: "Admin · Academy Billing" }), noData ? /* @__PURE__ */ React.createElement("div", { className: "mb-6 rounded-2xl border border-sky-300/20 bg-sky-300/[0.06] px-5 py-4 text-sm text-sky-100" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold" }, "No subscriber data in the database yet."), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sky-100/70" }, "Subscription records are created when Stripe sends webhook events to this server after a completed checkout. In local development, use", " ", /* @__PURE__ */ React.createElement("code", { className: "rounded bg-black/30 px-1.5 py-0.5 font-mono text-xs" }, "stripe listen --forward-to ", window.location.origin, "/stripe/webhook"), " ", "to forward events. On production, confirm the Stripe webhook is configured and active.")) : null, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 sm:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement(StatCard$d, { label: "Active Subscribers", value: summary.active_subscribers || 0 }), /* @__PURE__ */ React.createElement(StatCard$d, { label: "Creator Subscribers", value: summary.creator_subscribers || 0 }), /* @__PURE__ */ React.createElement(StatCard$d, { label: "Pro Subscribers", value: summary.pro_subscribers || 0 }), /* @__PURE__ */ React.createElement(StatCard$d, { label: "Grace Period", value: summary.grace_period_subscribers || 0, hint: "Canceled subscriptions that still keep access until the billing period ends." })), /* @__PURE__ */ React.createElement("div", { className: "mt-8 grid gap-6 xl:grid-cols-[1.2fr_0.8fr]" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Plan Health"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-xl font-semibold text-white" }, "Configured Academy plans")), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement(xe, { href: links.dashboard, className: "rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-semibold text-slate-200 transition hover:border-white/15 hover:bg-white/[0.06] hover:text-white" }, "Dashboard"), /* @__PURE__ */ React.createElement(xe, { href: links.pricing, className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/30 hover:bg-sky-300/15" }, "Public pricing"), /* @__PURE__ */ React.createElement(xe, { href: links.account, className: "rounded-full border border-emerald-300/20 bg-emerald-300/10 px-4 py-2 text-sm font-semibold text-emerald-100 transition hover:border-emerald-300/30 hover:bg-emerald-300/15" }, "My billing account"))), missingPlans.length ? /* @__PURE__ */ React.createElement("div", { className: "mt-5 rounded-2xl border border-amber-300/25 bg-amber-300/10 px-4 py-3 text-sm text-amber-100" }, "Missing Stripe price IDs for: ", missingPlans.join(", ")) : /* @__PURE__ */ React.createElement("div", { className: "mt-5 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100" }, "All configured Academy plans have Stripe price IDs."), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-3 md:grid-cols-2" }, planBreakdown.map((plan) => /* @__PURE__ */ React.createElement("div", { key: plan.key, className: "rounded-2xl border border-white/[0.08] bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-base font-semibold text-white" }, plan.label), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, plan.tier, " · ", plan.interval)), /* @__PURE__ */ React.createElement("span", { className: `rounded-full px-2.5 py-1 text-xs font-semibold uppercase tracking-[0.18em] ${plan.configured ? "bg-emerald-300/12 text-emerald-100" : "bg-amber-300/12 text-amber-100"}` }, plan.configured ? "configured" : "missing")), /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-3xl font-bold text-white" }, (plan.subscribers || 0).toLocaleString()), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "active subscriptions on this plan"))))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Webhook Sync"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-xl font-semibold text-white" }, "Recent Stripe activity"), /* @__PURE__ */ React.createElement("div", { className: "mt-5 space-y-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/[0.08] bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm text-slate-400" }, "Billing enabled"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-lg font-semibold text-white" }, summary.enabled ? "Yes" : "No")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/[0.08] bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm text-slate-400" }, "Webhook audits stored"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-lg font-semibold text-white" }, (summary.recent_webhook_count || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/[0.08] bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm text-slate-400" }, "Last processed webhook"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-lg font-semibold text-white" }, formatTimestamp$1(summary.last_webhook_at))), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/[0.08] bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm text-slate-400" }, "Ended subscriptions"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-lg font-semibold text-white" }, (summary.ended_subscriptions || 0).toLocaleString()))))), /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Audit Trail"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-xl font-semibold text-white" }, "Latest academy billing events")), /* @__PURE__ */ React.createElement("p", { className: "text-sm text-slate-400" }, "Only the safe local summary is stored, not the raw Stripe payload.")), /* @__PURE__ */ React.createElement("div", { className: "mt-5 overflow-x-auto" }, /* @__PURE__ */ React.createElement("table", { className: "min-w-full divide-y divide-white/[0.08] text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("thead", null, /* @__PURE__ */ React.createElement("tr", { className: "text-left text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, /* @__PURE__ */ React.createElement("th", { className: "px-3 py-3" }, "Event"), /* @__PURE__ */ React.createElement("th", { className: "px-3 py-3" }, "Plan"), /* @__PURE__ */ React.createElement("th", { className: "px-3 py-3" }, "Tier"), /* @__PURE__ */ React.createElement("th", { className: "px-3 py-3" }, "User"), /* @__PURE__ */ React.createElement("th", { className: "px-3 py-3" }, "Processed"), /* @__PURE__ */ React.createElement("th", { className: "px-3 py-3" }, "Summary"))), /* @__PURE__ */ React.createElement("tbody", { className: "divide-y divide-white/[0.06]" }, recentEvents.length ? recentEvents.map((event) => /* @__PURE__ */ React.createElement("tr", { key: event.id }, /* @__PURE__ */ React.createElement("td", { className: "px-3 py-3 font-medium text-white" }, event.event_type), /* @__PURE__ */ React.createElement("td", { className: "px-3 py-3" }, event.academy_plan || "n/a"), /* @__PURE__ */ React.createElement("td", { className: "px-3 py-3" }, event.academy_tier || "n/a"), /* @__PURE__ */ React.createElement("td", { className: "px-3 py-3" }, event.user_id || "guest/unresolved"), /* @__PURE__ */ React.createElement("td", { className: "px-3 py-3" }, formatTimestamp$1(event.processed_at || event.created_at)), /* @__PURE__ */ React.createElement("td", { className: "px-3 py-3 text-slate-400" }, formatEventSummary(event.payload_summary)))) : /* @__PURE__ */ React.createElement("tr", null, /* @__PURE__ */ React.createElement("td", { colSpan: "6", className: "px-3 py-6 text-center text-slate-400" }, "No Academy billing webhook audits have been stored yet."))))))); +} +const __vite_glob_0_16 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ + __proto__: null, + default: AcademyBilling +}, Symbol.toStringTag, { value: "Module" })); function laneKey(sectionId) { return sectionId == null ? "unsectioned" : `section:${sectionId}`; } @@ -13935,7 +15169,7 @@ function AcademyCourseBuilder({ course, sections = [], courseLessons = [], avail } ), /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: attachForm.processing || !attachForm.data.lesson_id, className: "w-full rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100" }, attachForm.processing ? "Attaching..." : "Attach lesson")))))); } -const __vite_glob_0_7 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_17 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AcademyCourseBuilder }, Symbol.toStringTag, { value: "Module" })); @@ -15860,7 +17094,7 @@ function RichTextEditor({ RichCompare.configure({ HTMLAttributes: { class: "rich-compare-node" } }), - Table.configure({ + Table$1.configure({ resizable: true, allowTableNodeSelection: true, HTMLAttributes: { @@ -16692,6 +17926,8 @@ const MONTH_NAMES = [ "November", "December" ]; +const YEAR_MIN = 1900; +const YEAR_MAX = 2105; const DAY_ABBR = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]; function pad(value) { return String(value).padStart(2, "0"); @@ -16712,14 +17948,30 @@ function parseDatePart(value) { if (!year || !month || !day) return null; return new Date(year, month - 1, day); } -function splitDateTime(value) { - if (!value) { - return { date: "", time: "" }; +function normalizeDateTimeInput(value) { + const raw = String(value || "").trim(); + if (!raw) return { date: "", time: "" }; + const match = raw.match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2})(?::\d{2})?)?(?:Z|[+-]\d{2}:?\d{2})?$/); + if (match) { + return { + date: match[1], + time: match[2] || "" + }; + } + const parsed = new Date(raw); + if (Number.isNaN(parsed.getTime())) { + return { date: raw, time: "" }; } - const [date = "", time = ""] = String(value).split("T"); return { - date, - time: time.slice(0, 5) + date: toISODate(parsed), + time: `${pad(parsed.getHours())}:${pad(parsed.getMinutes())}` + }; +} +function splitDateTime(value) { + const normalized = normalizeDateTimeInput(value); + return { + date: normalized.date, + time: normalized.time.slice(0, 5) }; } function mergeDateTime(date, time) { @@ -16993,7 +18245,7 @@ function DateTimePicker({ className: "fixed z-[500] overflow-hidden rounded-2xl border border-white/12 bg-nova-900 shadow-2xl shadow-black/50", style: { top: dropPos.top, left: dropPos.left, width: dropPos.width } }, - /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between px-3 pt-3" }, /* @__PURE__ */ React.createElement( + /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-2 px-3 pt-3" }, /* @__PURE__ */ React.createElement( "button", { type: "button", @@ -17002,7 +18254,27 @@ function DateTimePicker({ "aria-label": "Previous month" }, /* @__PURE__ */ React.createElement("svg", { width: "10", height: "10", viewBox: "0 0 10 10", fill: "none", "aria-hidden": "true" }, /* @__PURE__ */ React.createElement("path", { d: "M7 1L3 5l4 4", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" })) - ), /* @__PURE__ */ React.createElement("span", { className: "text-sm font-semibold text-white" }, MONTH_NAMES[viewMonth], " ", viewYear), /* @__PURE__ */ React.createElement( + ), /* @__PURE__ */ React.createElement("div", { className: "flex min-w-0 flex-1 items-center justify-center gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "whitespace-nowrap text-sm font-semibold text-white" }, MONTH_NAMES[viewMonth]), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-1 rounded-xl border border-white/10 bg-white/[0.04] px-1 py-1" }, /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + onClick: () => setViewYear((current) => Math.max(YEAR_MIN, current - 1)), + disabled: viewYear <= YEAR_MIN, + className: "flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-all hover:bg-white/8 hover:text-white disabled:cursor-not-allowed disabled:opacity-30", + "aria-label": "Previous year" + }, + /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-minus text-[11px]", "aria-hidden": "true" }) + ), /* @__PURE__ */ React.createElement("span", { className: "min-w-[72px] px-2 text-center text-sm font-semibold text-white" }, viewYear), /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + onClick: () => setViewYear((current) => Math.min(YEAR_MAX, current + 1)), + disabled: viewYear >= YEAR_MAX, + className: "flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-all hover:bg-white/8 hover:text-white disabled:cursor-not-allowed disabled:opacity-30", + "aria-label": "Next year" + }, + /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-plus text-[11px]", "aria-hidden": "true" }) + ))), /* @__PURE__ */ React.createElement( "button", { type: "button", @@ -17054,6 +18326,60 @@ function DateTimePicker({ document.body )); } +function ShareToast({ message = "Link copied!", visible = false, onHide, duration = 2e3, variant = "success" }) { + const [show, setShow] = reactExports.useState(false); + const config = variant === "error" ? { + border: "border-rose-300/25", + background: "bg-rose-950/90", + text: "text-rose-50", + icon: "text-rose-300", + role: "alert", + live: "assertive", + iconPath: "M12 9v3.75m0 3.75h.007v.008H12v-.008ZM10.29 3.86 1.82 18a1.875 1.875 0 0 0 1.606 2.813h16.148A1.875 1.875 0 0 0 21.18 18L12.71 3.86a1.875 1.875 0 0 0-3.42 0Z" + } : { + border: "border-white/[0.10]", + background: "bg-nova-800/90", + text: "text-white", + icon: "text-emerald-400", + role: "status", + live: "polite", + iconPath: "M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" + }; + reactExports.useEffect(() => { + if (visible) { + const enterTimer = requestAnimationFrame(() => setShow(true)); + const hideTimer = setTimeout(() => { + setShow(false); + setTimeout(() => onHide?.(), 200); + }, duration); + return () => { + cancelAnimationFrame(enterTimer); + clearTimeout(hideTimer); + }; + } else { + setShow(false); + } + }, [visible, duration, onHide]); + if (!visible) return null; + return reactDomExports.createPortal( + /* @__PURE__ */ React.createElement( + "div", + { + role: config.role, + "aria-live": config.live, + className: [ + "fixed bottom-24 left-1/2 z-[10001] -translate-x-1/2 rounded-full border px-5 py-2.5 text-sm font-medium shadow-xl backdrop-blur-md transition-all duration-200", + config.border, + config.background, + config.text, + show ? "translate-y-0 opacity-100" : "translate-y-3 opacity-0" + ].join(" ") + }, + /* @__PURE__ */ React.createElement("span", { className: "flex items-center gap-2" }, /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 20 20", fill: "currentColor", className: `h-4 w-4 ${config.icon}` }, /* @__PURE__ */ React.createElement("path", { fillRule: "evenodd", d: config.iconPath, clipRule: "evenodd" })), message) + ), + document.body + ); +} const COURSE_EDITOR_TABS = [ { id: "overview", @@ -17177,6 +18503,89 @@ function formatLessonStep(orderNum) { if (!Number.isFinite(numeric) || numeric < 0) return null; return `Step ${String(numeric + 1).padStart(2, "0")}`; } +function lessonActivityBadgeMeta(lesson) { + const isActive = Boolean(lesson?.active); + return { + label: isActive ? "Active" : "Inactive", + className: isActive ? "border-emerald-300/20 bg-emerald-300/10 text-emerald-100" : "border-amber-300/20 bg-amber-300/10 text-amber-200" + }; +} +function lessonPublicationBadgeMeta(lesson) { + const state = String(lesson?.publication_state || "draft"); + const label = String(lesson?.publication_label || (state === "published" ? "Published" : state === "scheduled" ? "Scheduled" : "Unscheduled")); + if (state === "published") { + return { + label, + className: "border-sky-300/20 bg-sky-300/10 text-sky-100" + }; + } + if (state === "scheduled") { + return { + label, + className: "border-fuchsia-300/20 bg-fuchsia-300/10 text-fuchsia-100" + }; + } + return { + label, + className: "border-white/10 bg-white/[0.04] text-slate-400" + }; +} +function CourseSectionCreateCard({ storeUrl, nextOrderNum }) { + const form = G$1({ + title: "", + slug: "", + description: "", + order_num: nextOrderNum, + is_visible: true + }); + reactExports.useEffect(() => { + form.setData("order_num", nextOrderNum); + }, [nextOrderNum]); + const createSection = () => { + if (!storeUrl) return; + form.post(storeUrl, { + preserveScroll: true, + onSuccess: () => { + form.setData({ + title: "", + slug: "", + description: "", + order_num: nextOrderNum, + is_visible: true + }); + } + }); + }; + return /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "New section"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, "Create a new course section here, then assign lessons into it below.")), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, "Order ", Number(nextOrderNum || 0) + 1)), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-4 lg:grid-cols-2" }, /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Title"), /* @__PURE__ */ React.createElement("input", { value: form.data.title, onChange: (event) => form.setData("title", event.target.value), placeholder: "Section 1 - Foundations", className: "rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement(FieldError$3, { message: form.errors.title })), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Slug"), /* @__PURE__ */ React.createElement("input", { value: form.data.slug, onChange: (event) => form.setData("slug", event.target.value), placeholder: "section-1-foundations", className: "rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement(FieldError$3, { message: form.errors.slug })), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200 lg:col-span-2" }, /* @__PURE__ */ React.createElement("span", null, "Description"), /* @__PURE__ */ React.createElement("textarea", { value: form.data.description, onChange: (event) => form.setData("description", event.target.value), rows: 3, placeholder: "What this section covers and why it matters.", className: "rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement(FieldError$3, { message: form.errors.description })), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Order"), /* @__PURE__ */ React.createElement("input", { type: "number", min: "0", value: form.data.order_num, onChange: (event) => form.setData("order_num", event.target.value), className: "rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement(FieldError$3, { message: form.errors.order_num })), /* @__PURE__ */ React.createElement("label", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("input", { type: "checkbox", checked: Boolean(form.data.is_visible), onChange: (event) => form.setData("is_visible", event.target.checked), className: "h-4 w-4 rounded border-white/20 bg-slate-950 text-sky-300" }), /* @__PURE__ */ React.createElement("span", null, "Visible on the public course outline"))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: createSection, disabled: form.processing || !String(form.data.title || "").trim(), className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100 disabled:opacity-40" }, form.processing ? "Creating…" : "Create section"))); +} +function CourseSectionCard({ section }) { + const form = G$1({ + title: section.title || "", + slug: section.slug || "", + description: section.description || "", + order_num: section.order_num || 0, + is_visible: Boolean(section.is_visible) + }); + reactExports.useEffect(() => { + form.setData({ + title: section.title || "", + slug: section.slug || "", + description: section.description || "", + order_num: section.order_num || 0, + is_visible: Boolean(section.is_visible) + }); + }, [section.description, section.is_visible, section.order_num, section.slug, section.title]); + const saveSection = () => { + if (!section.update_url) return; + form.patch(section.update_url, { preserveScroll: true }); + }; + const deleteSection = () => { + if (!section.destroy_url) return; + if (!window.confirm(`Delete section "${section.title}"? Lessons assigned to it will become unsectioned.`)) return; + At.delete(section.destroy_url, { preserveScroll: true }); + }; + return /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 lg:grid-cols-2" }, /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Title"), /* @__PURE__ */ React.createElement("input", { value: form.data.title, onChange: (event) => form.setData("title", event.target.value), className: "rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement(FieldError$3, { message: form.errors.title })), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Slug"), /* @__PURE__ */ React.createElement("input", { value: form.data.slug, onChange: (event) => form.setData("slug", event.target.value), className: "rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement(FieldError$3, { message: form.errors.slug })), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200 lg:col-span-2" }, /* @__PURE__ */ React.createElement("span", null, "Description"), /* @__PURE__ */ React.createElement("textarea", { value: form.data.description, onChange: (event) => form.setData("description", event.target.value), rows: 3, className: "rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement(FieldError$3, { message: form.errors.description })), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Order"), /* @__PURE__ */ React.createElement("input", { type: "number", min: "0", value: form.data.order_num, onChange: (event) => form.setData("order_num", event.target.value), className: "rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement(FieldError$3, { message: form.errors.order_num })), /* @__PURE__ */ React.createElement("label", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("input", { type: "checkbox", checked: Boolean(form.data.is_visible), onChange: (event) => form.setData("is_visible", event.target.checked), className: "h-4 w-4 rounded border-white/20 bg-slate-950 text-sky-300" }), /* @__PURE__ */ React.createElement("span", null, "Visible on the public course outline"))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: saveSection, disabled: form.processing || !String(form.data.title || "").trim(), className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100 disabled:opacity-40" }, form.processing ? "Saving…" : "Save section"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: deleteSection, className: "rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100" }, "Delete"))); +} function normalizeLessonManagerLessons(lessons) { return (Array.isArray(lessons) ? [...lessons] : []).sort((a, b2) => { const diff = Number(a?.order_num || 0) - Number(b2?.order_num || 0); @@ -17237,6 +18646,394 @@ function courseTabErrorCounts(errors) { }); return counts; } +function firstErrorMessage$3(errors, fallback = "Please correct the highlighted fields and try again.") { + const queue = [errors]; + while (queue.length > 0) { + const current = queue.shift(); + if (typeof current === "string") { + const message = current.trim(); + if (message) { + return message; + } + continue; + } + if (Array.isArray(current)) { + queue.push(...current); + continue; + } + if (current && typeof current === "object") { + queue.push(...Object.values(current)); + } + } + return fallback; +} +function normalizeImportBoolean(value, fallback = false) { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["true", "1", "yes", "y"].includes(normalized)) return true; + if (["false", "0", "no", "n"].includes(normalized)) return false; + } + if (typeof value === "number") { + if (value === 1) return true; + if (value === 0) return false; + } + return fallback; +} +function normalizeImportTags(value) { + if (Array.isArray(value)) { + return value.map((tag) => String(tag || "").trim()).filter(Boolean); + } + if (typeof value === "string") { + return value.split(/[\n,]/).map((tag) => tag.trim()).filter(Boolean); + } + return []; +} +function normalizeImportedDateTime$1(value) { + const raw = String(value || "").trim(); + if (!raw) return ""; + const dateTimeMatch = raw.match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2})(?::\d{2})?)?$/); + if (dateTimeMatch) { + return dateTimeMatch[2] ? `${dateTimeMatch[1]}T${dateTimeMatch[2]}` : dateTimeMatch[1]; + } + const parsed = new Date(raw); + if (Number.isNaN(parsed.getTime())) { + return raw; + } + const pad2 = (input) => String(input).padStart(2, "0"); + return `${parsed.getFullYear()}-${pad2(parsed.getMonth() + 1)}-${pad2(parsed.getDate())}T${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`; +} +function buildCourseJsonImportPrompt(courseTitle) { + return [ + "Create valid JSON only for a Skinbase Academy course import.", + "Do not wrap the answer in markdown fences or extra explanation.", + "Return a single object with course fields as keys.", + "Use strings for text values, booleans for featured flags, and YYYY-MM-DDTHH:mm for published_at when present.", + "Allowed keys: title, slug, subtitle, excerpt, description, cover_image, teaser_image, access_level, difficulty, status, order_num, estimated_minutes, published_at, seo_title, seo_description, meta_keywords, og_title, og_description, og_image, is_featured.", + "meta_keywords may be an array of strings or a comma-separated string.", + "Omit unknown keys.", + "Example shape:", + "{", + ' "title": "Course title",', + ' "slug": "course-title",', + ' "subtitle": "Short positioning line",', + ' "excerpt": "Short summary for cards.",', + ' "description": "Long form course description.",', + ' "access_level": "free",', + ' "difficulty": "beginner",', + ' "status": "draft",', + ' "is_featured": false', + "}", + `Course title context: ${String(courseTitle || "Untitled academy course")}` + ].join("\n"); +} +function parseCourseJsonImport(rawText) { + let parsed; + try { + parsed = JSON.parse(String(rawText || "")); + } catch { + throw new Error("Could not parse JSON."); + } + const root2 = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed.course && typeof parsed.course === "object" && !Array.isArray(parsed.course) ? parsed.course : parsed.data && typeof parsed.data === "object" && !Array.isArray(parsed.data) ? parsed.data : parsed.record && typeof parsed.record === "object" && !Array.isArray(parsed.record) ? parsed.record : parsed : null; + if (!root2 || typeof root2 !== "object" || Array.isArray(root2)) { + throw new Error("Import JSON must be an object."); + } + const next = {}; + const applyString = (targetKey, sourceKeys = [targetKey]) => { + const keys2 = Array.isArray(sourceKeys) ? sourceKeys : [sourceKeys]; + for (const key of keys2) { + if (root2[key] == null) continue; + const value = String(root2[key]).trim(); + if (!value) continue; + next[targetKey] = value; + return; + } + }; + const applyNumber = (targetKey, sourceKeys = [targetKey]) => { + const keys2 = Array.isArray(sourceKeys) ? sourceKeys : [sourceKeys]; + for (const key of keys2) { + if (root2[key] == null || String(root2[key]).trim() === "") continue; + const value = Number(root2[key]); + if (!Number.isFinite(value)) continue; + next[targetKey] = value; + return; + } + }; + const applyBoolean = (targetKey, sourceKeys = [targetKey]) => { + const keys2 = Array.isArray(sourceKeys) ? sourceKeys : [sourceKeys]; + for (const key of keys2) { + if (root2[key] == null) continue; + next[targetKey] = normalizeImportBoolean(root2[key], false); + return; + } + }; + applyString("title"); + applyString("slug"); + applyString("subtitle"); + applyString("excerpt"); + applyString("description"); + applyString("cover_image", ["cover_image", "cover_image_url", "hero_image", "hero_image_url"]); + applyString("teaser_image", ["teaser_image", "teaser_image_url"]); + applyString("access_level"); + applyString("difficulty"); + applyString("status"); + applyNumber("order_num"); + applyNumber("estimated_minutes"); + applyString("published_at"); + applyString("seo_title"); + applyString("seo_description"); + applyString("og_title"); + applyString("og_description"); + applyString("og_image"); + applyBoolean("is_featured", ["is_featured", "featured"]); + if (root2.meta_keywords != null) { + next.meta_keywords = Array.isArray(root2.meta_keywords) ? root2.meta_keywords.map((keyword) => String(keyword || "").trim()).filter(Boolean).join(", ") : String(root2.meta_keywords).trim(); + } + if (!next.slug && next.title) { + next.slug = slugifyCourseTitle(next.title); + } + if (next.published_at) { + next.published_at = normalizeImportedDateTime$1(next.published_at); + } + return next; +} +function buildCourseLessonImportPrompt(courseTitle, difficulty) { + return [ + "Create valid JSON only for a Skinbase Academy course lesson import.", + "Do not wrap the answer in markdown fences.", + "Return an object with this shape:", + "{", + ' "defaults": {', + ` "difficulty": "${String(difficulty)}",`, + ' "access_level": "free",', + ' "lesson_type": "article",', + ' "active": false', + " },", + ' "lessons": [', + " {", + ' "order": 1,', + ' "title": "Lesson title",', + ' "slug": "lesson-title",', + ' "goal": "One sentence learning goal for the lesson."', + " }", + " ]", + "}", + "Requirements:", + "- One lesson object per TOC row.", + "- Keep slugs lowercase and hyphenated.", + "- Keep goal concise and outcome-focused.", + "- Do not include body content or HTML. These should stay empty lesson shells.", + "- If category is known, you may include category or category_slug.", + `Course title: ${String(courseTitle || "Untitled academy course")}` + ].join("\n"); +} +function CourseJsonImportDialog({ open, value, error, exampleValue, promptValue, actionLabel = "Apply JSON", processing = false, onChange, onClose, onApply, onCopyExample, onCopyPrompt }) { + const backdropRef = reactExports.useRef(null); + const [activeReferenceTab, setActiveReferenceTab] = reactExports.useState("import"); + reactExports.useEffect(() => { + if (open) { + setActiveReferenceTab("import"); + } + }, [open]); + reactExports.useEffect(() => { + if (!open) return void 0; + const handleKeyDown2 = (event) => { + if (event.key === "Escape") { + onClose?.(); + } + }; + window.addEventListener("keydown", handleKeyDown2); + return () => window.removeEventListener("keydown", handleKeyDown2); + }, [onClose, open]); + if (!open) return null; + return reactDomExports.createPortal( + /* @__PURE__ */ React.createElement( + "div", + { + ref: backdropRef, + className: "fixed inset-0 z-[9999] flex items-start justify-center overflow-y-auto bg-[#04070dcc] px-4 py-4 backdrop-blur-md sm:items-center sm:px-6 sm:py-6", + onClick: (event) => { + if (event.target === backdropRef.current) { + onClose?.(); + } + }, + role: "presentation" + }, + /* @__PURE__ */ React.createElement("div", { role: "dialog", "aria-modal": "true", "aria-labelledby": "course-json-import-title", className: "flex max-h-[calc(100vh-2rem)] w-full max-w-6xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)] sm:max-h-[calc(100vh-3rem)]" }, /* @__PURE__ */ React.createElement("div", { className: "border-b border-white/[0.06] bg-white/[0.02] px-6 py-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35" }, "Course JSON import"), /* @__PURE__ */ React.createElement("h3", { id: "course-json-import-title", className: "mt-2 text-lg font-semibold text-white" }, "Paste course import JSON"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-4xl text-sm leading-6 text-white/65" }, "Use this to seed the course form from AI output, editorial drafts, or migrated course data without opening each field manually.")), /* @__PURE__ */ React.createElement("div", { className: "border-b border-white/[0.06] px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 md:grid-cols-3" }, [ + { id: "import", label: "Import", description: "Paste course JSON and apply it to the form." }, + { id: "docs", label: "Documentation", description: "Field guide and import rules." }, + { id: "prompts", label: "Prompt library", description: "Copy prompts for ChatGPT." } + ].map((tab2) => { + const isActive = tab2.id === activeReferenceTab; + return /* @__PURE__ */ React.createElement("button", { key: tab2.id, type: "button", onClick: () => setActiveReferenceTab(tab2.id), className: [ + "rounded-2xl border px-4 py-3 text-left transition", + isActive ? "border-sky-300/25 bg-sky-300/12 text-sky-100 ring-1 ring-sky-300/20" : "border-white/10 bg-white/[0.03] text-slate-300 hover:border-sky-300/30 hover:bg-sky-300/10 hover:text-white" + ].join(" ") }, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold" }, tab2.label), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs leading-5 text-current/70" }, tab2.description)); + }))), /* @__PURE__ */ React.createElement("div", { className: "min-h-0 flex-1 overflow-y-auto px-6 py-5" }, activeReferenceTab === "import" ? /* @__PURE__ */ React.createElement("div", { className: "grid min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3" }, /* @__PURE__ */ React.createElement( + "textarea", + { + value, + onChange: (event) => onChange?.(event.target.value), + rows: 18, + placeholder: exampleValue, + className: "w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-4 font-mono text-sm leading-6 text-white outline-none placeholder:text-white/30" + } + ), error ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100" }, error) : null), /* @__PURE__ */ React.createElement("div", { className: "grid content-start gap-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Example JSON"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: onCopyExample, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]" }, "Copy example")), /* @__PURE__ */ React.createElement("pre", { className: "mt-3 max-h-[360px] overflow-auto rounded-2xl border border-white/10 bg-slate-950/70 p-3 text-xs leading-6 text-slate-300 whitespace-pre-wrap" }, exampleValue)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Recognized keys"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "title, slug, subtitle, excerpt, description"), /* @__PURE__ */ React.createElement("p", null, "cover_image, teaser_image"), /* @__PURE__ */ React.createElement("p", null, "access_level, difficulty, status"), /* @__PURE__ */ React.createElement("p", null, "order_num, estimated_minutes, published_at"), /* @__PURE__ */ React.createElement("p", null, "seo_title, seo_description"), /* @__PURE__ */ React.createElement("p", null, "meta_keywords"), /* @__PURE__ */ React.createElement("p", null, "og_title, og_description, og_image"), /* @__PURE__ */ React.createElement("p", null, "is_featured"))))) : null, activeReferenceTab === "docs" ? /* @__PURE__ */ React.createElement("div", { className: "grid min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Field guide"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-3 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "title"), " - public course name used on cards, headers, and metadata."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "slug"), " - URL slug. If omitted, it will be generated from the title."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "subtitle"), " - compact supporting line shown near the title."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "excerpt"), " - short summary for cards and compact previews."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "description"), " - longer editorial description or syllabus overview."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "cover_image"), " and ", /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "teaser_image"), " - stored paths or external URLs."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "access_level"), " - use ", /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "free"), ", ", /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "premium"), ", or ", /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "mixed"), "."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "difficulty"), " - use ", /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "beginner"), ", ", /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "intermediate"), ", or ", /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "advanced"), "."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "status"), " - use ", /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "draft"), ", ", /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "review"), ", ", /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "published"), ", or ", /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "archived"), "."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "published_at"), " - accepts ISO strings or ", /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "YYYY-MM-DDTHH:mm"), "."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "meta_keywords"), " - array or comma-separated string."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "is_featured"), " - JSON boolean."))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Import rules"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "Unknown keys are ignored, so broader AI output is safe to paste."), /* @__PURE__ */ React.createElement("p", null, "Use JSON booleans for ", /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "is_featured"), "."), /* @__PURE__ */ React.createElement("p", null, "Keep ", /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "order_num"), " and ", /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "estimated_minutes"), " numeric."), /* @__PURE__ */ React.createElement("p", null, "Use ", /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "published_at"), " only when you want a prefilled publish timestamp."), /* @__PURE__ */ React.createElement("p", null, "Separate title, summary, and metadata so the course form stays readable after import.")))) : null, activeReferenceTab === "prompts" ? /* @__PURE__ */ React.createElement("div", { className: "grid min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "grid min-w-0 gap-4" }, [ + { + title: "Draft a course JSON object from a syllabus", + prompt: `Create valid JSON only for a Skinbase Academy course import. + +Return a single object with these keys when relevant: title, slug, subtitle, excerpt, description, cover_image, teaser_image, access_level, difficulty, status, order_num, estimated_minutes, published_at, seo_title, seo_description, meta_keywords, og_title, og_description, og_image, is_featured. + +Rules: +- Do not use markdown fences or explanation text. +- Omit unknown keys. +- Use strings for text, booleans for featured flags, and YYYY-MM-DDTHH:mm for published_at. +- meta_keywords may be an array or comma-separated string. +- Keep the tone editorial, concise, and import-safe.` + }, + { + title: "Turn AI notes into course import JSON", + prompt: `You are preparing JSON for a Skinbase Academy course form. Output JSON only. + +Write a single object with clean course metadata. Use lowercase hyphenated slugs, concise excerpts, and production-ready SEO fields. If the source notes do not mention a field, omit it. Set is_featured to true only when the course should be highlighted.` + } + ].map((example) => /* @__PURE__ */ React.createElement("div", { key: example.title, className: "min-w-0 overflow-hidden rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-200/70" }, example.title), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => onCopyPrompt?.(example.prompt, example.title), className: "shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]" }, "Copy prompt")), /* @__PURE__ */ React.createElement("pre", { className: "mt-3 max-h-56 min-w-0 overflow-auto whitespace-pre-wrap break-words rounded-[18px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-6 text-slate-200 [overflow-wrap:anywhere]" }, example.prompt)))), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 self-start rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Prompt tips"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "Ask the model to return JSON only, with no markdown or commentary."), /* @__PURE__ */ React.createElement("p", null, "Tell it to omit any field it cannot populate confidently."), /* @__PURE__ */ React.createElement("p", null, "Use ", /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "slug"), " only when you need a custom URL, otherwise let the form derive it from the title."), /* @__PURE__ */ React.createElement("p", null, "Keep ", /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "meta_keywords"), " short and focused on search intent.")))) : null), /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => onClose?.(), className: "inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white" }, "Cancel"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => onApply?.(), disabled: processing, className: "inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60" }, processing ? "Working..." : actionLabel))) + ), + document.body + ); +} +function parseCourseLessonImport(rawText, categoryOptions = []) { + let parsed; + try { + parsed = JSON.parse(String(rawText || "")); + } catch { + throw new Error("Could not parse JSON."); + } + const root2 = Array.isArray(parsed) ? { lessons: parsed } : parsed; + if (!root2 || typeof root2 !== "object" || Array.isArray(root2)) { + throw new Error("Import JSON must be an array or an object with a lessons array."); + } + const lessonSource = Array.isArray(root2.lessons) ? root2.lessons : Array.isArray(root2.toc) ? root2.toc : Array.isArray(root2.items) ? root2.items : null; + if (!Array.isArray(lessonSource) || lessonSource.length === 0) { + throw new Error("Import JSON must contain a lessons array with at least one item."); + } + const categoryMatches = Array.isArray(categoryOptions) ? categoryOptions : []; + const defaultsSource = root2.defaults && typeof root2.defaults === "object" && !Array.isArray(root2.defaults) ? root2.defaults : {}; + const defaults2 = {}; + if (defaultsSource.category_id != null) defaults2.category_id = Number(defaultsSource.category_id); + if (defaultsSource.category_slug != null) defaults2.category_slug = String(defaultsSource.category_slug).trim(); + if (defaultsSource.category != null) defaults2.category = String(defaultsSource.category).trim(); + if (defaultsSource.difficulty != null) defaults2.difficulty = String(defaultsSource.difficulty).trim(); + if (defaultsSource.access_level != null) defaults2.access_level = String(defaultsSource.access_level).trim(); + if (defaultsSource.lesson_type != null) defaults2.lesson_type = String(defaultsSource.lesson_type).trim(); + if (defaultsSource.series_name != null) defaults2.series_name = String(defaultsSource.series_name).trim(); + if (defaultsSource.active != null) defaults2.active = normalizeImportBoolean(defaultsSource.active, false); + const lessons = lessonSource.map((item, index2) => { + const source = item && typeof item === "object" && !Array.isArray(item) ? item : { title: String(item || "") }; + const title = String(source.title ?? source.lesson_title ?? source.lesson ?? source.name ?? "").trim(); + if (!title) { + return null; + } + const next = { + title, + slug: String(source.slug ?? "").trim(), + goal: String(source.goal ?? source.objective ?? source.summary ?? source.description ?? "").trim(), + excerpt: String(source.excerpt ?? "").trim(), + difficulty: String(source.difficulty ?? "").trim(), + access_level: String(source.access_level ?? source.access ?? "").trim(), + lesson_type: String(source.lesson_type ?? source.type ?? "").trim(), + series_name: String(source.series_name ?? "").trim(), + tags: normalizeImportTags(source.tags), + active: source.active == null ? void 0 : normalizeImportBoolean(source.active, false), + _sortOrder: Number(source.order ?? source.lesson_number ?? source.position ?? index2 + 1) + }; + const requestedCategory = String(source.category_id ?? source.category_slug ?? source.category ?? "").trim().toLowerCase(); + if (requestedCategory) { + const match = categoryMatches.find((option) => [option.id, option.value, option.slug, option.name, option.label].filter((candidate) => candidate != null).map((candidate) => String(candidate).trim().toLowerCase()).includes(requestedCategory)); + if (match?.id != null) { + next.category_id = Number(match.id); + } else if (source.category_slug != null) { + next.category_slug = String(source.category_slug).trim(); + } else if (source.category != null) { + next.category = String(source.category).trim(); + } + } + return next; + }).filter(Boolean).sort((a, b2) => a._sortOrder - b2._sortOrder).map(({ _sortOrder, ...lesson }) => lesson); + if (lessons.length === 0) { + throw new Error("The JSON did not contain any lesson rows with a title."); + } + return { defaults: defaults2, lessons }; +} +function CourseLessonJsonImportDialog({ open, value, error, exampleValue, promptValue, onChange, onClose, onApply, onCopyExample, onCopyPrompt }) { + const backdropRef = reactExports.useRef(null); + const [activeReferenceTab, setActiveReferenceTab] = reactExports.useState("structure"); + reactExports.useEffect(() => { + if (open) { + setActiveReferenceTab("structure"); + } + }, [open]); + reactExports.useEffect(() => { + if (!open) return void 0; + const handleKeyDown2 = (event) => { + if (event.key === "Escape") { + onClose?.(); + } + }; + window.addEventListener("keydown", handleKeyDown2); + return () => window.removeEventListener("keydown", handleKeyDown2); + }, [onClose, open]); + if (!open) return null; + return reactDomExports.createPortal( + /* @__PURE__ */ React.createElement( + "div", + { + ref: backdropRef, + className: "fixed inset-0 z-[9999] flex items-start justify-center overflow-y-auto bg-[#04070dcc] px-4 py-4 backdrop-blur-md sm:items-center sm:px-6 sm:py-6", + onClick: (event) => { + if (event.target === backdropRef.current) { + onClose?.(); + } + }, + role: "presentation" + }, + /* @__PURE__ */ React.createElement("div", { className: "flex max-h-[calc(100vh-2rem)] w-full max-w-6xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)] sm:max-h-[calc(100vh-3rem)]" }, /* @__PURE__ */ React.createElement("div", { className: "border-b border-white/[0.06] bg-white/[0.02] px-6 py-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35" }, "Course TOC Import"), /* @__PURE__ */ React.createElement("h3", { className: "mt-2 text-lg font-semibold text-white" }, "Paste lesson import JSON"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-4xl text-sm leading-6 text-white/65" }, "Paste a table-of-contents payload here and the editor will create empty lesson pages already attached to this course in the imported order.")), /* @__PURE__ */ React.createElement("div", { className: "min-h-0 flex-1 overflow-y-auto" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 px-6 py-5 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3" }, /* @__PURE__ */ React.createElement( + "textarea", + { + value, + onChange: (event) => onChange?.(event.target.value), + rows: 16, + placeholder: exampleValue, + className: "min-h-[320px] rounded-[24px] border border-white/10 bg-slate-950/80 px-4 py-4 font-mono text-sm leading-6 text-slate-100 outline-none" + } + ), error ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100" }, error) : null), /* @__PURE__ */ React.createElement("div", { className: "grid content-start gap-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-2" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2", role: "tablist", "aria-label": "Import help panels" }, [ + { id: "structure", label: "Structure", icon: "fa-brackets-curly" }, + { id: "prompt", label: "Prompt", icon: "fa-wand-magic-sparkles" }, + { id: "applied", label: "Applied", icon: "fa-list-check" } + ].map((tab2) => { + const isActive = tab2.id === activeReferenceTab; + return /* @__PURE__ */ React.createElement( + "button", + { + key: tab2.id, + type: "button", + role: "tab", + "aria-selected": isActive, + onClick: () => setActiveReferenceTab(tab2.id), + className: [ + "inline-flex items-center gap-2 rounded-2xl border px-3.5 py-2 text-xs font-semibold uppercase tracking-[0.14em] transition", + isActive ? "border-sky-300/25 bg-sky-300/12 text-sky-100 ring-1 ring-sky-300/20" : "border-white/10 bg-white/[0.03] text-slate-400 hover:border-sky-300/30 hover:bg-sky-300/10 hover:text-white" + ].join(" ") + }, + /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${tab2.icon} text-[10px]` }), + /* @__PURE__ */ React.createElement("span", null, tab2.label) + ); + })), /* @__PURE__ */ React.createElement("div", { className: "mt-3 rounded-[20px] border border-white/10 bg-slate-950/50 p-4 text-sm text-slate-300" }, activeReferenceTab === "structure" ? /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Accepted structure"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: onCopyExample, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]" }, "Copy example")), /* @__PURE__ */ React.createElement("pre", { className: "mt-3 max-h-[360px] overflow-auto rounded-2xl border border-white/10 bg-slate-950/70 p-3 text-xs leading-6 text-slate-300" }, exampleValue)) : null, activeReferenceTab === "prompt" ? /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "ChatGPT helper prompt"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: onCopyPrompt, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]" }, "Copy prompt")), /* @__PURE__ */ React.createElement("pre", { className: "mt-3 max-h-[360px] overflow-auto rounded-2xl border border-white/10 bg-slate-950/70 p-3 text-xs leading-6 text-slate-300 whitespace-pre-wrap" }, promptValue)) : null, activeReferenceTab === "applied" ? /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "What gets applied"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "Each row creates a new Academy lesson record."), /* @__PURE__ */ React.createElement("p", null, "The lesson is attached to this course in the imported order."), /* @__PURE__ */ React.createElement("p", null, "Title, slug, goal/excerpt, category, difficulty, access, and lesson type can be imported."), /* @__PURE__ */ React.createElement("p", null, "Body content stays empty so the lesson can be written later."))) : null))))), /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => onClose?.(), className: "inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white" }, "Cancel"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => onApply?.(), className: "inline-flex items-center gap-2 justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-file-import text-xs" }), /* @__PURE__ */ React.createElement("span", null, "Import lessons")))) + ), + document.body + ); +} function renderMetaKeywords(value) { return String(value || "").split(/[\n,]/).map((item) => item.trim()).filter(Boolean).slice(0, 6); } @@ -17265,23 +19062,92 @@ function CourseEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de const sectionClassName = (sectionId, className = "") => `${visibleSections.has(sectionId) ? "" : "hidden"} ${className}`.trim(); const editorLinks = editorContext?.links || {}; const outlineSummary = editorContext?.outlineSummary || null; + const courseSectionsSource = reactExports.useMemo(() => Array.isArray(editorContext?.courseSections) ? editorContext.courseSections : [], [editorContext]); const coursePathPreview = form.data.slug ? `/academy/courses/${form.data.slug}` : "/academy/courses/course-slug"; const metaKeywordItems = renderMetaKeywords(form.data.meta_keywords); const attachLessonUrl = editorContext?.attachLessonUrl || null; + const importLessonsUrl = editorContext?.importLessonsUrl || null; + const sectionStoreUrl = editorContext?.sectionStoreUrl || null; const reorderUrl = editorContext?.reorderUrl || null; const courseLessonsSource = reactExports.useMemo(() => Array.isArray(editorContext?.courseLessons) ? editorContext.courseLessons : [], [editorContext]); const availableLessons = reactExports.useMemo(() => Array.isArray(editorContext?.availableLessons) ? editorContext.availableLessons : [], [editorContext]); + const lessonCategoryOptions = reactExports.useMemo(() => Array.isArray(editorContext?.lessonCategoryOptions) ? editorContext.lessonCategoryOptions : [], [editorContext]); + const courseImportUrl = editorContext?.courseImportUrl || null; const [lessonManagerDraft, setLessonManagerDraft] = reactExports.useState(() => normalizeLessonManagerLessons(Array.isArray(editorContext?.courseLessons) ? editorContext.courseLessons : [])); const [lessonDragActive, setLessonDragActive] = reactExports.useState(null); const [lessonSaveProcessing, setLessonSaveProcessing] = reactExports.useState(false); const [lessonSearch, setLessonSearch] = reactExports.useState(""); + const [jsonImportOpen, setJsonImportOpen] = reactExports.useState(false); + const [jsonImportValue, setJsonImportValue] = reactExports.useState(""); + const [jsonImportError, setJsonImportError] = reactExports.useState(""); + const [courseJsonImportOpen, setCourseJsonImportOpen] = reactExports.useState(false); + const [courseJsonImportValue, setCourseJsonImportValue] = reactExports.useState(""); + const [courseJsonImportError, setCourseJsonImportError] = reactExports.useState(""); + const [courseJsonImportProcessing, setCourseJsonImportProcessing] = reactExports.useState(false); + const [toast, setToast] = reactExports.useState({ id: 0, visible: false, message: "", variant: "success" }); const lessonManagerIsDirty = reactExports.useMemo(() => lessonManagerSignature(lessonManagerDraft) !== lessonManagerSignature(courseLessonsSource), [lessonManagerDraft, courseLessonsSource]); + const importPromptValue = reactExports.useMemo(() => buildCourseLessonImportPrompt(form.data.title || title, form.data.difficulty || "beginner"), [form.data.difficulty, form.data.title, title]); + const importExampleValue = reactExports.useMemo(() => JSON.stringify({ + defaults: { + difficulty: String(form.data.difficulty || "beginner"), + access_level: "free", + lesson_type: "article", + active: false + }, + lessons: [ + { + order: 1, + title: "What Makes a Great Wallpaper Prompt?", + slug: "what-makes-a-great-wallpaper-prompt", + goal: "Explain what separates random AI images from clean, usable wallpapers." + }, + { + order: 2, + title: "The Anatomy of a Strong AI Art Prompt", + slug: "the-anatomy-of-a-strong-ai-art-prompt", + goal: "Teach the core prompt structure: subject, scene, style, lighting, composition, quality, and restrictions." + } + ] + }, null, 2), [form.data.difficulty]); + const courseImportPromptValue = reactExports.useMemo(() => buildCourseJsonImportPrompt(form.data.title || title), [form.data.title, title]); + const courseImportExampleValue = reactExports.useMemo(() => JSON.stringify({ + title: form.data.title || "Editorial course title", + slug: slugifyCourseTitle(form.data.title || "Editorial course title"), + subtitle: "Short positioning line for the course.", + excerpt: "One or two sentences that summarize the course.", + description: "Long form course description, syllabus overview, or editorial pitch.", + cover_image: "https://files.skinbase.org/path/to/course-cover.webp", + teaser_image: "https://files.skinbase.org/path/to/course-teaser.webp", + access_level: String(form.data.access_level || "free"), + difficulty: String(form.data.difficulty || "beginner"), + status: String(form.data.status || "draft"), + order_num: Number(form.data.order_num || 0), + estimated_minutes: Number(form.data.estimated_minutes || 45), + published_at: "2026-05-17T10:00", + seo_title: "Editorial course SEO title", + seo_description: "Editorial course SEO description for search and social previews.", + meta_keywords: ["academy course", "editorial workflow", "learning path"], + og_title: "OpenGraph title", + og_description: "OpenGraph description", + og_image: "https://files.skinbase.org/path/to/course-og.webp", + is_featured: Boolean(form.data.is_featured) + }, null, 2), [form.data.access_level, form.data.difficulty, form.data.estimated_minutes, form.data.is_featured, form.data.order_num, form.data.status, form.data.title, title]); + const showToast = (message, variant = "error") => { + setToast({ + id: Date.now() + Math.random(), + visible: true, + message, + variant + }); + }; const filteredAvailableLessons = reactExports.useMemo(() => { const q2 = lessonSearch.trim().toLowerCase(); const unattached = availableLessons.filter((l3) => !l3.attached); if (!q2) return unattached; return unattached.filter((l3) => l3.title.toLowerCase().includes(q2) || l3.category.toLowerCase().includes(q2)); }, [availableLessons, lessonSearch]); + const sectionOptions = reactExports.useMemo(() => [{ value: "", label: "No section" }, ...courseSectionsSource.map((section) => ({ value: String(section.id), label: section.title }))], [courseSectionsSource]); + const nextSectionOrderNum = reactExports.useMemo(() => courseSectionsSource.reduce((maxOrder, section) => Math.max(maxOrder, Number(section?.order_num || 0)), -1) + 1, [courseSectionsSource]); reactExports.useEffect(() => { setLessonManagerDraft(normalizeLessonManagerLessons(courseLessonsSource)); }, [courseLessonsSource]); @@ -17316,7 +19182,10 @@ function CourseEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de if (!reorderUrl) return; setLessonSaveProcessing(true); At.patch(reorderUrl, { - sections: [], + sections: courseSectionsSource.map((section) => ({ + id: section.id, + order_num: Number(section.order_num || 0) + })), lessons: lessonManagerDraft.map((l3) => ({ id: l3.id, order_num: l3.order_num, @@ -17327,6 +19196,85 @@ function CourseEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de onFinish: () => setLessonSaveProcessing(false) }); }; + const copyImportText = async (value, label) => { + try { + await navigator.clipboard.writeText(value); + showToast(`${label} copied.`, "success"); + } catch { + showToast(`Could not copy ${label.toLowerCase()}.`, "error"); + } + }; + const applyCourseJsonImport = () => { + try { + const payload = parseCourseJsonImport(courseJsonImportValue); + setCourseJsonImportError(""); + if (courseImportUrl && method === "post") { + setCourseJsonImportProcessing(true); + At.post(courseImportUrl, payload, { + preserveScroll: true, + onError: (errors) => { + const message = firstErrorMessage$3(errors, "Could not import the course JSON."); + setCourseJsonImportError(message); + showToast(message, "error"); + }, + onSuccess: () => { + setCourseJsonImportOpen(false); + setCourseJsonImportValue(""); + setCourseJsonImportError(""); + showToast("Course JSON imported.", "success"); + }, + onFinish: () => setCourseJsonImportProcessing(false) + }); + return; + } + Object.entries(payload).forEach(([key, value]) => { + form.setData(key, value); + }); + if (!payload.slug && payload.title) { + form.setData("slug", slugifyCourseTitle(payload.title)); + } + setCourseJsonImportOpen(false); + setCourseJsonImportValue(""); + showToast("Course JSON applied.", "success"); + } catch (error) { + setCourseJsonImportError(error instanceof Error ? error.message : "Could not parse JSON."); + } + }; + const applyLessonImport = () => { + if (!importLessonsUrl) return; + try { + const payload = parseCourseLessonImport(jsonImportValue, lessonCategoryOptions); + setJsonImportError(""); + At.post(importLessonsUrl, payload, { + preserveScroll: true, + onError: (errors) => { + const message = firstErrorMessage$3(errors, "Could not import the lesson TOC."); + setJsonImportError(message); + showToast(message, "error"); + }, + onSuccess: () => { + setJsonImportOpen(false); + setJsonImportValue(""); + setJsonImportError(""); + showToast("Lesson TOC imported.", "success"); + } + }); + } catch (error) { + setJsonImportError(error instanceof Error ? error.message : "Could not parse JSON."); + } + }; + const updateDraftLessonSection = (lessonId, nextSectionId) => { + setLessonManagerDraft((current) => normalizeLessonManagerLessons(current.map((lesson) => { + if (Number(lesson.id) !== Number(lessonId)) { + return lesson; + } + return { + ...lesson, + section_id: nextSectionId === "" ? null : Number(nextSectionId), + section_title: nextSectionId === "" ? "" : courseSectionsSource.find((section) => String(section.id) === String(nextSectionId))?.title || "" + }; + }))); + }; const handleManualTeaserChange = (nextValue) => { setStagedTeaserPath(""); form.setData("teaser_image", nextValue); @@ -17334,18 +19282,28 @@ function CourseEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de }; const submit = (event) => { event.preventDefault(); + const submitOptions = { + preserveScroll: true, + onError: (errors) => { + const nextTab = firstCourseErrorTab(errors); + if (nextTab) { + setActiveTab(nextTab); + } + showToast(firstErrorMessage$3(errors), "error"); + } + }; if (method === "patch") { - form.patch(submitUrl); + form.patch(submitUrl, submitOptions); return; } - form.post(submitUrl); + form.post(submitUrl, submitOptions); }; const deleteCourse = () => { if (!destroyUrl) return; if (!window.confirm("Delete this course?")) return; At.delete(destroyUrl); }; - return /* @__PURE__ */ React.createElement(AdminLayout, { title, subtitle }, /* @__PURE__ */ React.createElement(Se$1, { title: `Admin · ${title}` }), /* @__PURE__ */ React.createElement("form", { onSubmit: submit, className: "space-y-6 pb-16" }, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.94))] shadow-[0_24px_70px_rgba(2,6,23,0.34)] backdrop-blur" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-4 border-b border-white/10 px-5 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400" }, /* @__PURE__ */ React.createElement(xe, { href: indexUrl, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white transition hover:bg-white/[0.08]" }, "Back to courses"), /* @__PURE__ */ React.createElement("span", null, destroyUrl ? "Edit course" : "New course")), /* @__PURE__ */ React.createElement("h1", { className: "mt-3 text-3xl font-semibold tracking-[-0.05em] text-white" }, form.data.title || "Untitled academy course"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-3xl text-sm leading-7 text-slate-300" }, "Design the course like a polished editorial landing page: keep the structure clear, use the rich description editor, and upload visuals that look intentional on the public cards and hero.")), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, editorLinks.builder ? /* @__PURE__ */ React.createElement(xe, { href: editorLinks.builder, className: "rounded-2xl border border-amber-300/20 bg-amber-300/10 px-4 py-2.5 text-sm font-semibold text-amber-100 transition hover:brightness-110" }, "Open builder") : null, editorLinks.preview ? /* @__PURE__ */ React.createElement(xe, { href: editorLinks.preview, className: "rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, "Preview public page") : null, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: form.processing, className: "rounded-2xl border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-sm font-semibold text-sky-100" }, form.processing ? "Saving..." : "Save course")))), /* @__PURE__ */ React.createElement(EditorWorkspaceTabs$1, { tabs: COURSE_EDITOR_TABS, activeTab, onChange: setActiveTab, errorCounts: tabErrorCounts }), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.14)]" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Current workspace"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, activeTabMeta.label), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-2xl text-sm leading-6 text-slate-400" }, activeTabMeta.description)), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 sm:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Words"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, wordCount.toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Excerpt"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, excerptLength, "/800")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Errors"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, Object.keys(form.errors || {}).length))))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0 space-y-6", role: "tabpanel", id: `course-editor-panel-${activeTab}`, "aria-labelledby": `course-editor-tab-${activeTab}` }, /* @__PURE__ */ React.createElement(SectionCard$6, { id: "course-identity", eyebrow: "Positioning", title: "Identity and summary", description: "Start with the public-facing identity shown on the course index, hero, and internal Academy modules.", tone: "feature", className: sectionClassName("course-identity") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement( + return /* @__PURE__ */ React.createElement(AdminLayout, { title, subtitle }, /* @__PURE__ */ React.createElement(Se$1, { title: `Admin · ${title}` }), /* @__PURE__ */ React.createElement("form", { onSubmit: submit, className: "space-y-6 pb-16" }, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.94))] shadow-[0_24px_70px_rgba(2,6,23,0.34)] backdrop-blur" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-4 border-b border-white/10 px-5 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400" }, /* @__PURE__ */ React.createElement(xe, { href: indexUrl, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white transition hover:bg-white/[0.08]" }, "Back to courses"), /* @__PURE__ */ React.createElement("span", null, destroyUrl ? "Edit course" : "New course")), /* @__PURE__ */ React.createElement("h1", { className: "mt-3 text-3xl font-semibold tracking-[-0.05em] text-white" }, form.data.title || "Untitled academy course"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-3xl text-sm leading-7 text-slate-300" }, "Design the course like a polished editorial landing page: keep the structure clear, use the rich description editor, and upload visuals that look intentional on the public cards and hero.")), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, importLessonsUrl ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => setJsonImportOpen(true), className: "inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-file-import text-xs" }), /* @__PURE__ */ React.createElement("span", null, "Import lessons JSON")) : null, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => setCourseJsonImportOpen(true), className: "inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-file-import text-xs" }), /* @__PURE__ */ React.createElement("span", null, "Import JSON")), editorLinks.builder ? /* @__PURE__ */ React.createElement(xe, { href: editorLinks.builder, className: "rounded-2xl border border-amber-300/20 bg-amber-300/10 px-4 py-2.5 text-sm font-semibold text-amber-100 transition hover:brightness-110" }, "Open builder") : null, editorLinks.preview ? /* @__PURE__ */ React.createElement(xe, { href: editorLinks.preview, className: "rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, "Preview public page") : null, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: form.processing, className: "rounded-2xl border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-sm font-semibold text-sky-100" }, form.processing ? "Saving..." : "Save course")))), /* @__PURE__ */ React.createElement(EditorWorkspaceTabs$1, { tabs: COURSE_EDITOR_TABS, activeTab, onChange: setActiveTab, errorCounts: tabErrorCounts }), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.14)]" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Current workspace"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, activeTabMeta.label), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-2xl text-sm leading-6 text-slate-400" }, activeTabMeta.description)), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 sm:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Words"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, wordCount.toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Excerpt"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, excerptLength, "/800")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Errors"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, Object.keys(form.errors || {}).length))))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0 space-y-6", role: "tabpanel", id: `course-editor-panel-${activeTab}`, "aria-labelledby": `course-editor-tab-${activeTab}` }, /* @__PURE__ */ React.createElement(SectionCard$6, { id: "course-identity", eyebrow: "Positioning", title: "Identity and summary", description: "Start with the public-facing identity shown on the course index, hero, and internal Academy modules.", tone: "feature", className: sectionClassName("course-identity") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement( TextField$3, { label: "Title", @@ -17483,9 +19441,10 @@ function CourseEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de description: "Add lessons from the library, drag rows to reorder, use the arrows for precision, and save the updated sequence. Removing a lesson detaches it from this course immediately.", tone: "feature", className: sectionClassName("course-lessons-manager"), - actions: editorLinks.builder ? /* @__PURE__ */ React.createElement("a", { href: editorLinks.builder, className: "rounded-2xl border border-amber-300/20 bg-amber-300/10 px-4 py-2.5 text-sm font-semibold text-amber-100 transition hover:brightness-110" }, "Open full builder") : null + actions: /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, importLessonsUrl ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => setJsonImportOpen(true), className: "inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-file-import text-xs" }), /* @__PURE__ */ React.createElement("span", null, "Import TOC JSON")) : null, editorLinks.builder ? /* @__PURE__ */ React.createElement("a", { href: editorLinks.builder, className: "rounded-2xl border border-amber-300/20 bg-amber-300/10 px-4 py-2.5 text-sm font-semibold text-amber-100 transition hover:brightness-110" }, "Open full builder") : null) }, - /* @__PURE__ */ React.createElement("div", { className: "space-y-2" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Lesson sequence", lessonManagerDraft.length > 0 ? /* @__PURE__ */ React.createElement("span", { className: "ml-2 rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] text-slate-300" }, lessonManagerDraft.length) : null, lessonManagerIsDirty ? /* @__PURE__ */ React.createElement("span", { className: "ml-2 rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-0.5 text-[10px] text-amber-200" }, "Unsaved order") : null), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement( + /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]" }, /* @__PURE__ */ React.createElement(CourseSectionCreateCard, { storeUrl: sectionStoreUrl, nextOrderNum: nextSectionOrderNum }), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Sections in this course"), courseSectionsSource.length > 0 ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, courseSectionsSource.length, " total") : null), courseSectionsSource.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 rounded-[20px] border border-dashed border-white/10 bg-black/20 px-4 py-5 text-sm text-slate-400" }, "No sections yet. Create one here, then assign lessons into it below.") : /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3" }, courseSectionsSource.map((section) => /* @__PURE__ */ React.createElement(CourseSectionCard, { key: section.id, section }))))), + /* @__PURE__ */ React.createElement("div", { className: "mt-6 space-y-2" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Lesson sequence", lessonManagerDraft.length > 0 ? /* @__PURE__ */ React.createElement("span", { className: "ml-2 rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] text-slate-300" }, lessonManagerDraft.length) : null, lessonManagerIsDirty ? /* @__PURE__ */ React.createElement("span", { className: "ml-2 rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-0.5 text-[10px] text-amber-200" }, "Unsaved order") : null), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement( "button", { type: "button", @@ -17523,7 +19482,15 @@ function CourseEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de lessonDragActive && Number(lessonDragActive.id) === Number(lesson.id) ? "opacity-50 border-sky-300/30" : "" ].join(" ") }, - /* @__PURE__ */ React.createElement("div", { className: "flex min-w-0 items-center gap-3" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-grip-vertical text-xs text-slate-600" }), /* @__PURE__ */ React.createElement("div", { className: "min-w-0" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-black/20 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300" }, formatLessonStep(lesson.order_num) || `#${lesson.display_order}`), lesson.formatted_lesson_number ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300" }, lesson.formatted_lesson_number) : null, lesson.section_title ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400" }, lesson.section_title) : null, lesson.difficulty ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400" }, lesson.difficulty) : null), /* @__PURE__ */ React.createElement("p", { className: "mt-1.5 truncate text-sm font-semibold text-white" }, lesson.title))), + /* @__PURE__ */ React.createElement("div", { className: "flex min-w-0 items-center gap-3" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-grip-vertical text-xs text-slate-600" }), lesson.cover_image_url ? /* @__PURE__ */ React.createElement("div", { className: "h-14 w-20 overflow-hidden rounded-2xl border border-white/10 bg-black/20" }, /* @__PURE__ */ React.createElement("img", { src: lesson.cover_image_url, alt: "", className: "h-full w-full object-cover", loading: "lazy" })) : null, /* @__PURE__ */ React.createElement("div", { className: "min-w-0" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-black/20 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300" }, formatLessonStep(lesson.order_num) || `#${lesson.display_order}`), lesson.formatted_lesson_number ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300" }, lesson.formatted_lesson_number) : null, lesson.section_title ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400" }, lesson.section_title) : null, lesson.difficulty ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400" }, lesson.difficulty) : null, /* @__PURE__ */ React.createElement("span", { className: `rounded-full px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] ${lessonActivityBadgeMeta(lesson).className}` }, lessonActivityBadgeMeta(lesson).label), /* @__PURE__ */ React.createElement("span", { className: `rounded-full px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] ${lessonPublicationBadgeMeta(lesson).className}` }, lessonPublicationBadgeMeta(lesson).label)), /* @__PURE__ */ React.createElement("p", { className: "mt-1.5 truncate text-sm font-semibold text-white" }, lesson.title), /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("label", { className: "text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500" }, "Section"), /* @__PURE__ */ React.createElement( + "select", + { + value: lesson.section_id == null ? "" : String(lesson.section_id), + onChange: (event) => updateDraftLessonSection(lesson.id, event.target.value), + className: "rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-xs font-semibold text-white outline-none" + }, + sectionOptions.map((option) => /* @__PURE__ */ React.createElement("option", { key: option.value || "none", value: option.value, className: "bg-slate-950 text-white" }, option.label)) + ), /* @__PURE__ */ React.createElement("span", { className: "text-xs text-slate-500" }, "Pick a section here, then save order to apply the grouping.")))), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement( "button", { @@ -17554,16 +19521,16 @@ function CourseEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de "Remove" )) ))), - /* @__PURE__ */ React.createElement("div", { className: "mt-6 space-y-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Add from lesson library"), /* @__PURE__ */ React.createElement("div", { className: "relative" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-magnifying-glass absolute left-4 top-1/2 -translate-y-1/2 text-xs text-slate-500" }), /* @__PURE__ */ React.createElement( + /* @__PURE__ */ React.createElement("div", { className: "mt-6 space-y-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Add from unassigned lesson library"), /* @__PURE__ */ React.createElement("div", { className: "relative" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-magnifying-glass absolute left-4 top-1/2 -translate-y-1/2 text-xs text-slate-500" }), /* @__PURE__ */ React.createElement( "input", { type: "search", value: lessonSearch, onChange: (e) => setLessonSearch(e.target.value), - placeholder: "Search lessons by title or category…", + placeholder: "Search unassigned lessons by title or category…", className: "w-full rounded-2xl border border-white/10 bg-black/20 py-2.5 pl-9 pr-4 text-sm text-white outline-none placeholder:text-slate-600" } - )), filteredAvailableLessons.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-white/[0.03] px-5 py-4 text-sm text-slate-500" }, lessonSearch.trim() ? "No unattached lessons match your search." : "All lessons are already attached to this course.") : /* @__PURE__ */ React.createElement("div", { className: "grid gap-2" }, filteredAvailableLessons.map((lesson) => /* @__PURE__ */ React.createElement("div", { key: lesson.id, className: "flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, lesson.difficulty ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400" }, lesson.difficulty) : null, lesson.category ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400" }, lesson.category) : null, !lesson.active ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-200" }, "Inactive") : null), /* @__PURE__ */ React.createElement("p", { className: "mt-1.5 truncate text-sm font-semibold text-white" }, lesson.title)), /* @__PURE__ */ React.createElement( + )), filteredAvailableLessons.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-white/[0.03] px-5 py-4 text-sm text-slate-500" }, lessonSearch.trim() ? "No unassigned lessons match your search." : "All lessons are already assigned to a course.") : /* @__PURE__ */ React.createElement("div", { className: "grid gap-2" }, filteredAvailableLessons.map((lesson) => /* @__PURE__ */ React.createElement("div", { key: lesson.id, className: "flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex min-w-0 items-center gap-3" }, lesson.cover_image_url ? /* @__PURE__ */ React.createElement("div", { className: "h-14 w-20 overflow-hidden rounded-2xl border border-white/10 bg-black/20" }, /* @__PURE__ */ React.createElement("img", { src: lesson.cover_image_url, alt: "", className: "h-full w-full object-cover", loading: "lazy" })) : null, /* @__PURE__ */ React.createElement("div", { className: "min-w-0" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, lesson.difficulty ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400" }, lesson.difficulty) : null, lesson.category ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400" }, lesson.category) : null, /* @__PURE__ */ React.createElement("span", { className: `rounded-full px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] ${lessonActivityBadgeMeta(lesson).className}` }, lessonActivityBadgeMeta(lesson).label), /* @__PURE__ */ React.createElement("span", { className: `rounded-full px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] ${lessonPublicationBadgeMeta(lesson).className}` }, lessonPublicationBadgeMeta(lesson).label)), /* @__PURE__ */ React.createElement("p", { className: "mt-1.5 truncate text-sm font-semibold text-white" }, lesson.title))), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, lesson.edit_url ? /* @__PURE__ */ React.createElement("a", { href: lesson.edit_url, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-pen-to-square text-[10px]" }), /* @__PURE__ */ React.createElement("span", null, "Edit lesson")) : null, /* @__PURE__ */ React.createElement( "button", { type: "button", @@ -17571,7 +19538,7 @@ function CourseEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-3 py-1.5 text-xs font-semibold text-sky-100" }, "Add to course" - ))))) + )))))) ), /* @__PURE__ */ React.createElement(SectionCard$6, { id: "course-publishing", eyebrow: "Release controls", title: "Access, status, and placement", description: "Choose how the course appears in Academy discovery surfaces and when it goes live.", className: sectionClassName("course-publishing") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement(NovaSelect, { label: "Access", value: form.data.access_level || "", onChange: (nextValue) => form.setData("access_level", String(nextValue || "")), options: accessField?.options || [], searchable: false, className: "bg-black/20", error: form.errors.access_level })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement(NovaSelect, { label: "Difficulty", value: form.data.difficulty || "", onChange: (nextValue) => form.setData("difficulty", String(nextValue || "")), options: difficultyField?.options || [], searchable: false, className: "bg-black/20", error: form.errors.difficulty })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement(NovaSelect, { label: "Status", value: form.data.status || "", onChange: (nextValue) => form.setData("status", String(nextValue || "")), options: statusField?.options || [], searchable: false, className: "bg-black/20", error: form.errors.status }))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement( TextField$3, { @@ -17593,9 +19560,52 @@ function CourseEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de description: "Use the featured treatment on Academy homepage rails and the course index. Keep this for courses with strong cover art and a finished outline.", error: form.errors.is_featured } - )), /* @__PURE__ */ React.createElement(SectionCard$6, { id: "course-seo", eyebrow: "Search surfaces", title: "SEO and OpenGraph", description: "Keep the course crawlable and shareable without overstuffing the main title.", className: sectionClassName("course-seo") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextField$3, { label: "SEO title", value: form.data.seo_title, onChange: (event) => form.setData("seo_title", event.target.value), error: form.errors.seo_title, maxLength: 180, placeholder: "Optional search title" }), /* @__PURE__ */ React.createElement(TextField$3, { label: "OpenGraph title", value: form.data.og_title, onChange: (event) => form.setData("og_title", event.target.value), error: form.errors.og_title, maxLength: 180, placeholder: "Optional social title" })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextAreaField$2, { label: "SEO description", value: form.data.seo_description, onChange: (event) => form.setData("seo_description", event.target.value), error: form.errors.seo_description, rows: 4, hint: "Keep this short and aligned with the course promise." }), /* @__PURE__ */ React.createElement(TextAreaField$2, { label: "OpenGraph description", value: form.data.og_description, onChange: (event) => form.setData("og_description", event.target.value), error: form.errors.og_description, rows: 4, hint: "Used when the course page is shared into external platforms." })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextAreaField$2, { label: "Meta keywords", value: form.data.meta_keywords, onChange: (event) => form.setData("meta_keywords", event.target.value), error: form.errors.meta_keywords, rows: 3, hint: "Comma-separated terms. Keep this focused and editorial, not spammy." }), /* @__PURE__ */ React.createElement(TextField$3, { label: "OpenGraph image", value: form.data.og_image, onChange: (event) => form.setData("og_image", event.target.value), error: form.errors.og_image, placeholder: "Leave empty to fall back to the course artwork" }))), /* @__PURE__ */ React.createElement(SectionCard$6, { id: "course-preview", eyebrow: "Public preview", title: "Rendered course snapshot", description: "Use this tab to scan the media mix, course promise, and rendered long description without the rest of the form competing for attention.", tone: "feature", className: sectionClassName("course-preview") }, /* @__PURE__ */ React.createElement("div", { className: "space-y-5" }, /* @__PURE__ */ React.createElement("div", { className: "overflow-hidden rounded-[28px] border border-white/10 bg-slate-950" }, coverPreviewUrl || teaserPreviewUrl ? /* @__PURE__ */ React.createElement("img", { src: coverPreviewUrl || teaserPreviewUrl, alt: "Course hero preview", className: "h-64 w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-64 items-center justify-center px-6 text-center text-sm text-slate-500" }, "No course artwork selected yet.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-black/20 p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100" }, form.data.difficulty || "beginner"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200" }, form.data.access_level || "free"), form.data.is_featured ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/20 bg-amber-300/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, "Featured") : null), /* @__PURE__ */ React.createElement("h3", { className: "mt-4 text-3xl font-semibold tracking-[-0.05em] text-white" }, form.data.title || "Untitled academy course"), form.data.subtitle ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold uppercase tracking-[0.18em] text-amber-100" }, form.data.subtitle) : null, /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm leading-7 text-slate-300" }, form.data.excerpt || "Add a short course summary to explain what this path helps creators accomplish.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-black/20 p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Description preview"), String(deferredDescription || "").trim() ? /* @__PURE__ */ React.createElement("div", { className: "prose prose-invert mt-4 max-w-none prose-headings:tracking-[-0.03em] prose-p:text-slate-300 prose-li:text-slate-300", dangerouslySetInnerHTML: { __html: deferredDescription } }) : /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm leading-7 text-slate-400" }, "The long description is still empty."))))), /* @__PURE__ */ React.createElement("div", { className: "space-y-6 xl:sticky xl:top-6 xl:self-start" }, /* @__PURE__ */ React.createElement(SectionCard$6, { eyebrow: "At a glance", title: "Course summary", description: "A compact view of the public URL, media readiness, and the metadata editors see most often." }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-sky-300/18 bg-sky-300/8 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80" }, "Public path"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 break-all text-sm font-semibold text-white" }, coursePathPreview), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, "Use a concise slug so the course URL stays readable in search results and internal links.")), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 sm:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Cover"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, coverPreviewUrl ? "Ready" : "Missing")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Teaser"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, teaserPreviewUrl ? "Ready" : "Missing")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Status"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, form.data.status || "draft")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Duration"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, form.data.estimated_minutes ? `${form.data.estimated_minutes} min` : "Flexible")))), outlineSummary ? /* @__PURE__ */ React.createElement(SectionCard$6, { eyebrow: "Builder pulse", title: "Course outline", description: "A quick summary of what the course builder currently contains so editors do not need to leave this form just to check structure." }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 sm:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Sections"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, outlineSummary.section_count)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Visible sections"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, outlineSummary.visible_section_count)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Attached lessons"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, outlineSummary.lesson_count)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Required lessons"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, outlineSummary.required_lesson_count))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-sky-300/18 bg-sky-300/8 px-4 py-4 text-sm leading-7 text-slate-300" }, outlineSummary.unsectioned_lesson_count > 0 ? `${outlineSummary.unsectioned_lesson_count} lesson${outlineSummary.unsectioned_lesson_count === 1 ? "" : "s"} still sit outside sections. Use the builder if you want the outline to read like a guided chapter path.` : "All attached lessons are currently grouped into sections."), outlineSummary.sections?.length ? /* @__PURE__ */ React.createElement("div", { className: "grid gap-3" }, outlineSummary.sections.map((section) => /* @__PURE__ */ React.createElement(OutlineSectionPill, { key: section.id, section }))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-white/[0.03] px-4 py-5 text-sm text-slate-400" }, "No sections yet. The builder will still allow unsectioned lessons, but adding chapters usually makes the public course easier to scan.")) : null, /* @__PURE__ */ React.createElement(SectionCard$6, { eyebrow: "Metadata pulse", title: "Search and share", description: "A quick scan of the metadata that most often gets missed before publish." }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "SEO title"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-white" }, form.data.seo_title || "Uses course title by default")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Keywords"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 flex flex-wrap gap-2" }, metaKeywordItems.length ? metaKeywordItems.map((item) => /* @__PURE__ */ React.createElement("span", { key: item, className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold text-slate-200" }, item)) : /* @__PURE__ */ React.createElement("span", { className: "text-sm text-slate-400" }, "No meta keywords yet."))))))), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: form.processing, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, form.processing ? "Saving..." : "Save course"), /* @__PURE__ */ React.createElement(xe, { href: indexUrl, className: "rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white" }, "Back"), destroyUrl ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: deleteCourse, className: "rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100" }, "Delete") : null))); + )), /* @__PURE__ */ React.createElement(SectionCard$6, { id: "course-seo", eyebrow: "Search surfaces", title: "SEO and OpenGraph", description: "Keep the course crawlable and shareable without overstuffing the main title.", className: sectionClassName("course-seo") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextField$3, { label: "SEO title", value: form.data.seo_title, onChange: (event) => form.setData("seo_title", event.target.value), error: form.errors.seo_title, maxLength: 180, placeholder: "Optional search title" }), /* @__PURE__ */ React.createElement(TextField$3, { label: "OpenGraph title", value: form.data.og_title, onChange: (event) => form.setData("og_title", event.target.value), error: form.errors.og_title, maxLength: 180, placeholder: "Optional social title" })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextAreaField$2, { label: "SEO description", value: form.data.seo_description, onChange: (event) => form.setData("seo_description", event.target.value), error: form.errors.seo_description, rows: 4, hint: "Keep this short and aligned with the course promise." }), /* @__PURE__ */ React.createElement(TextAreaField$2, { label: "OpenGraph description", value: form.data.og_description, onChange: (event) => form.setData("og_description", event.target.value), error: form.errors.og_description, rows: 4, hint: "Used when the course page is shared into external platforms." })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextAreaField$2, { label: "Meta keywords", value: form.data.meta_keywords, onChange: (event) => form.setData("meta_keywords", event.target.value), error: form.errors.meta_keywords, rows: 3, hint: "Comma-separated terms. Keep this focused and editorial, not spammy." }), /* @__PURE__ */ React.createElement(TextField$3, { label: "OpenGraph image", value: form.data.og_image, onChange: (event) => form.setData("og_image", event.target.value), error: form.errors.og_image, placeholder: "Leave empty to fall back to the course artwork" }))), /* @__PURE__ */ React.createElement(SectionCard$6, { id: "course-preview", eyebrow: "Public preview", title: "Rendered course snapshot", description: "Use this tab to scan the media mix, course promise, and rendered long description without the rest of the form competing for attention.", tone: "feature", className: sectionClassName("course-preview") }, /* @__PURE__ */ React.createElement("div", { className: "space-y-5" }, /* @__PURE__ */ React.createElement("div", { className: "overflow-hidden rounded-[28px] border border-white/10 bg-slate-950" }, coverPreviewUrl || teaserPreviewUrl ? /* @__PURE__ */ React.createElement("img", { src: coverPreviewUrl || teaserPreviewUrl, alt: "Course hero preview", className: "h-64 w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-64 items-center justify-center px-6 text-center text-sm text-slate-500" }, "No course artwork selected yet.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-black/20 p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100" }, form.data.difficulty || "beginner"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200" }, form.data.access_level || "free"), form.data.is_featured ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/20 bg-amber-300/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, "Featured") : null), /* @__PURE__ */ React.createElement("h3", { className: "mt-4 text-3xl font-semibold tracking-[-0.05em] text-white" }, form.data.title || "Untitled academy course"), form.data.subtitle ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold uppercase tracking-[0.18em] text-amber-100" }, form.data.subtitle) : null, /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm leading-7 text-slate-300" }, form.data.excerpt || "Add a short course summary to explain what this path helps creators accomplish.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-black/20 p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Description preview"), String(deferredDescription || "").trim() ? /* @__PURE__ */ React.createElement("div", { className: "prose prose-invert mt-4 max-w-none prose-headings:tracking-[-0.03em] prose-p:text-slate-300 prose-li:text-slate-300", dangerouslySetInnerHTML: { __html: deferredDescription } }) : /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm leading-7 text-slate-400" }, "The long description is still empty."))))), /* @__PURE__ */ React.createElement("div", { className: "space-y-6 xl:sticky xl:top-6 xl:self-start" }, /* @__PURE__ */ React.createElement(SectionCard$6, { eyebrow: "At a glance", title: "Course summary", description: "A compact view of the public URL, media readiness, and the metadata editors see most often." }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-sky-300/18 bg-sky-300/8 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80" }, "Public path"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 break-all text-sm font-semibold text-white" }, coursePathPreview), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, "Use a concise slug so the course URL stays readable in search results and internal links.")), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 sm:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Cover"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, coverPreviewUrl ? "Ready" : "Missing")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Teaser"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, teaserPreviewUrl ? "Ready" : "Missing")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Status"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, form.data.status || "draft")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Duration"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, form.data.estimated_minutes ? `${form.data.estimated_minutes} min` : "Flexible")))), outlineSummary ? /* @__PURE__ */ React.createElement(SectionCard$6, { eyebrow: "Builder pulse", title: "Course outline", description: "A quick summary of what the course builder currently contains so editors do not need to leave this form just to check structure." }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 sm:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Sections"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, outlineSummary.section_count)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Visible sections"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, outlineSummary.visible_section_count)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Attached lessons"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, outlineSummary.lesson_count)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Required lessons"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, outlineSummary.required_lesson_count))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-sky-300/18 bg-sky-300/8 px-4 py-4 text-sm leading-7 text-slate-300" }, outlineSummary.unsectioned_lesson_count > 0 ? `${outlineSummary.unsectioned_lesson_count} lesson${outlineSummary.unsectioned_lesson_count === 1 ? "" : "s"} still sit outside sections. Use the section controls above to group them into chapters.` : "All attached lessons are currently grouped into sections."), outlineSummary.sections?.length ? /* @__PURE__ */ React.createElement("div", { className: "grid gap-3" }, outlineSummary.sections.map((section) => /* @__PURE__ */ React.createElement(OutlineSectionPill, { key: section.id, section }))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-white/[0.03] px-4 py-5 text-sm text-slate-400" }, "No sections yet. The builder will still allow unsectioned lessons, but adding chapters usually makes the public course easier to scan.")) : null, /* @__PURE__ */ React.createElement(SectionCard$6, { eyebrow: "Metadata pulse", title: "Search and share", description: "A quick scan of the metadata that most often gets missed before publish." }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "SEO title"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-white" }, form.data.seo_title || "Uses course title by default")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Keywords"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 flex flex-wrap gap-2" }, metaKeywordItems.length ? metaKeywordItems.map((item) => /* @__PURE__ */ React.createElement("span", { key: item, className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold text-slate-200" }, item)) : /* @__PURE__ */ React.createElement("span", { className: "text-sm text-slate-400" }, "No meta keywords yet."))))))), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: form.processing, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, form.processing ? "Saving..." : "Save course"), /* @__PURE__ */ React.createElement(xe, { href: indexUrl, className: "rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white" }, "Back"), destroyUrl ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: deleteCourse, className: "rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100" }, "Delete") : null)), /* @__PURE__ */ React.createElement( + CourseJsonImportDialog, + { + open: courseJsonImportOpen, + value: courseJsonImportValue, + error: courseJsonImportError, + exampleValue: courseImportExampleValue, + promptValue: courseImportPromptValue, + actionLabel: courseImportUrl && method === "post" ? "Create course from JSON" : "Apply JSON", + processing: courseJsonImportProcessing, + onChange: setCourseJsonImportValue, + onClose: () => { + setCourseJsonImportOpen(false); + setCourseJsonImportError(""); + }, + onApply: applyCourseJsonImport, + onCopyExample: () => copyImportText(courseImportExampleValue, "Course example"), + onCopyPrompt: () => copyImportText(courseImportPromptValue, "Course prompt") + } + ), /* @__PURE__ */ React.createElement( + CourseLessonJsonImportDialog, + { + open: jsonImportOpen, + value: jsonImportValue, + error: jsonImportError, + exampleValue: importExampleValue, + promptValue: importPromptValue, + onChange: setJsonImportValue, + onClose: () => setJsonImportOpen(false), + onApply: applyLessonImport, + onCopyExample: () => copyImportText(importExampleValue, "Example JSON"), + onCopyPrompt: () => copyImportText(importPromptValue, "ChatGPT prompt") + } + ), /* @__PURE__ */ React.createElement( + ShareToast, + { + key: toast.id, + message: toast.message, + visible: toast.visible, + variant: toast.variant, + duration: toast.variant === "error" ? 3200 : 2200, + onHide: () => setToast((current) => ({ ...current, visible: false })) + } + )); } -const __vite_glob_0_8 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_18 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: CourseEditor }, Symbol.toStringTag, { value: "Module" })); @@ -18822,9 +20832,9 @@ const LESSON_EDITOR_TABS = [ { id: "assets", label: "Assets", - description: "Categories, hero media, and article imagery.", + description: "Hero cover, article cover, and lesson categories.", icon: "fa-images", - sections: ["lesson-categories", "lesson-cover", "lesson-article-cover"] + sections: ["lesson-cover", "lesson-article-cover", "lesson-categories"] }, { id: "revisions", @@ -18899,6 +20909,9 @@ function FieldError$2({ message }) { if (!message) return null; return /* @__PURE__ */ React.createElement("p", { className: "text-xs text-rose-300" }, message); } +function CopyablePromptCard({ eyebrow, title, description, prompt, onCopy }) { + return /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, eyebrow), /* @__PURE__ */ React.createElement("h3", { className: "mt-1 text-base font-semibold text-white" }, title), description ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, description) : null), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: onCopy, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]" }, "Copy prompt")), /* @__PURE__ */ React.createElement("textarea", { readOnly: true, value: prompt, rows: 10, spellCheck: false, className: "mt-4 w-full rounded-2xl border border-white/10 bg-slate-950/70 p-3 text-xs leading-6 text-slate-300 outline-none" })); +} function SectionCard$5({ id, eyebrow, title, description, actions, children, tone = "default", className = "", contentClassName = "" }) { const toneClass = tone === "feature" ? "bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] shadow-[0_24px_70px_rgba(2,6,23,0.28)]" : "bg-white/[0.03]"; return /* @__PURE__ */ React.createElement("section", { id, className: `min-w-0 scroll-mt-24 rounded-[28px] border border-white/10 p-5 ${toneClass} ${className}`.trim() }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", { className: "max-w-3xl" }, eyebrow ? /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/75" }, eyebrow) : null, /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-xl font-semibold tracking-[-0.03em] text-white" }, title), description ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, description) : null), actions ? /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, actions) : null), /* @__PURE__ */ React.createElement("div", { className: `mt-5 ${contentClassName}`.trim() }, children)); @@ -18944,6 +20957,27 @@ function lessonTabErrorCounts(errors) { }); return counts; } +function firstErrorMessage$2(errors, fallback = "Please correct the highlighted fields and try again.") { + const queue = [errors]; + while (queue.length > 0) { + const current = queue.shift(); + if (typeof current === "string") { + const message = current.trim(); + if (message) { + return message; + } + continue; + } + if (Array.isArray(current)) { + queue.push(...current); + continue; + } + if (current && typeof current === "object") { + queue.push(...Object.values(current)); + } + } + return fallback; +} function TextField$2({ label, value, onChange, error, hint, ...rest }) { return /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, label), /* @__PURE__ */ React.createElement("input", { value: value ?? "", onChange, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none", ...rest }), hint ? /* @__PURE__ */ React.createElement("span", { className: "text-xs leading-5 text-slate-500" }, hint) : null, /* @__PURE__ */ React.createElement(FieldError$2, { message: error })); } @@ -19246,8 +21280,137 @@ function parseLessonImport(rawText, categoryOptions) { } return { next, applied }; } -function JsonImportDialog$1({ open, value, error, onChange, onClose, onApply }) { +function buildLessonImportExample({ title, excerpt, difficulty, accessLevel, lessonType, categoryName }) { + const nextTitle = String(title || "").trim() || "How to Build Cleaner Prompt References"; + const nextExcerpt = String(excerpt || "").trim() || "Build a lesson draft with a clear promise, practical steps, and reusable examples."; + return JSON.stringify({ + title: nextTitle, + slug: slugifyLessonTitle(nextTitle), + excerpt: nextExcerpt, + category: String(categoryName || "").trim() || "Prompting", + difficulty: String(difficulty || "").trim() || "beginner", + access_level: String(accessLevel || "").trim() || "free", + lesson_type: String(lessonType || "").trim() || "article", + tags: ["prompting", "workflow", "editing"], + content_markdown: [ + "# Why this lesson matters", + "", + "Open with the promise of the lesson and the result the reader should get.", + "", + "## Core workflow", + "", + "- Step 1: Define the goal clearly.", + "- Step 2: Show the pattern or framework.", + "- Step 3: Add one concrete example.", + "", + "## Wrap up", + "", + "Close with the next action or checklist the reader should follow." + ].join("\n"), + reading_minutes: 8, + seo_title: nextTitle, + seo_description: nextExcerpt, + active: false + }, null, 2); +} +function buildLessonImportPrompt({ title, difficulty, accessLevel, lessonType, categoryName }) { + return [ + "Create valid JSON only for a Skinbase Academy lesson import.", + "Do not wrap the answer in markdown fences.", + "Return one object with this shape:", + "{", + ' "title": "Lesson title",', + ' "slug": "lesson-title",', + ' "excerpt": "One short summary sentence.",', + ` "category": "${String(categoryName || "Prompting")}",`, + ` "difficulty": "${String(difficulty || "beginner")}",`, + ` "access_level": "${String(accessLevel || "free")}",`, + ` "lesson_type": "${String(lessonType || "article")}",`, + ' "tags": ["tag-one", "tag-two"],', + ' "content_markdown": "# Heading\\n\\nWrite the lesson body in Markdown.",', + ' "reading_minutes": 8,', + ' "seo_title": "Optional SEO title",', + ' "seo_description": "Optional SEO description",', + ' "active": false', + "}", + "Requirements:", + "- Keep the response as valid JSON only.", + "- Prefer content_markdown over HTML unless HTML is explicitly requested.", + "- Keep excerpt concise and specific.", + "- Keep tags short and relevant.", + "- Use lowercase hyphenated slugs.", + "- Do not invent image URLs unless source assets are provided.", + `Current lesson title: ${String(title || "Untitled lesson")}` + ].join("\n"); +} +function buildLessonHeroPrompt({ title, excerpt, categoryName, tags = [] }) { + return [ + "Create a wide hero cover image for a Skinbase Academy lesson.", + `Lesson title: ${String(title || "Untitled lesson")}`, + `Lesson summary: ${String(excerpt || "No summary added yet.")}`, + `Category: ${String(categoryName || "Uncategorized")}`, + `Tags: ${tags.length > 0 ? tags.join(", ") : "none"}`, + "", + "Aspect ratio: 16:9 landscape.", + "Style: cinematic editorial artwork with premium lighting, a strong focal point, and a clean composition that still reads well when cropped into cards and previews.", + "Text rules: no added text, no captions, no logos, no watermarks, and no visible UI.", + "Composition: keep the center readable and leave safe space for future cropping.", + "Output: a single final image prompt, not a report." + ].join("\n"); +} +function buildLessonArticleCoverPrompt({ courseName, lessonNumber, title, excerpt, categoryName, tags = [], aspectRatio = "3:2", mainVisualSubject, previewImageDescription }) { + return [ + "Create a premium Skinbase Academy inline article cover image.", + "", + `Course name: ${String(courseName || "Unassigned")}`, + `Lesson number: ${String(lessonNumber || "1")}`, + `Lesson title: ${String(title || "Untitled lesson")}`, + `Lesson summary: ${String(excerpt || "No summary added yet.")}`, + `Category: ${String(categoryName || "Uncategorized")}`, + `Tags: ${tags.length > 0 ? tags.join(", ") : "none"}`, + "", + `Aspect ratio: ${String(aspectRatio || "3:2")}, landscape article-cover format.`, + "", + "Visual direction:", + "Design a polished dark editorial academy cover inspired by a modern creative-tech learning interface. The layout should feel like a premium lesson card for an online academy article.", + "", + "Composition:", + "Use a strong two-column layout.", + "Left side: large lesson-title area, lesson badge, short summary area, and a row of small educational icon blocks.", + "Right side: a large cinematic preview image inside a rounded rectangular frame, showing the lesson concept visually.", + "Below or near the preview image: add a subtle prompt/workflow card with abstract lines and interface-like blocks.", + "Bottom area: add a clean row of small learning-step modules or icon cards.", + "", + "Main visual subject:", + String(mainVisualSubject || `A premium editorial visual focused on ${String(title || "this lesson")}`), + "", + "The right preview image should show:", + String(previewImageDescription || `A cinematic article-cover scene that clearly supports ${String(title || "the lesson topic")} and feels premium at thumbnail size.`), + "", + "Educational UI details:", + "Include subtle composition guide lines, crop guides, small abstract icons, prompt-card shapes, clean rounded panels, soft glows, and thin purple outlines. Make the design feel structured, modern, and readable.", + "", + "Style:", + "Dark modern Skinbase Academy aesthetic, polished editorial design, premium creative-tech interface, cinematic digital art, clean hierarchy, soft shadows, rounded cards, subtle grid background, elegant purple/cyan accents, high-end course-platform look.", + "", + "Color palette:", + "Deep navy, black, dark violet, purple gradients, muted cyan highlights, soft white typography areas, warm cinematic orange/gold highlights inside the preview artwork.", + "", + "Text handling:", + "Use clean title-like placeholder text areas only. Do not create messy fake text. Keep typography areas visually readable and leave enough space for real text to be added later. Avoid small unreadable paragraphs.", + "", + "Important:", + "No logos, no watermarks, no brand marks, no fake signatures, no cluttered UI, no distorted icons, no random letters, no overcrowded composition. The cover must work as an inline article image and still be clear at thumbnail size." + ].join("\n"); +} +function JsonImportDialog$1({ open, value, error, exampleValue, promptValue, onChange, onClose, onApply, onCopyExample, onCopyPrompt }) { const backdropRef = reactExports.useRef(null); + const [activeReferenceTab, setActiveReferenceTab] = reactExports.useState("structure"); + reactExports.useEffect(() => { + if (open) { + setActiveReferenceTab("structure"); + } + }, [open]); reactExports.useEffect(() => { if (!open) return void 0; const handleKeyDown2 = (event) => { @@ -19264,7 +21427,7 @@ function JsonImportDialog$1({ open, value, error, onChange, onClose, onApply }) "div", { ref: backdropRef, - className: "fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md", + className: "fixed inset-0 z-[9999] flex items-start justify-center overflow-y-auto bg-[#04070dcc] px-4 py-4 backdrop-blur-md sm:items-center sm:px-6 sm:py-6", onClick: (event) => { if (event.target === backdropRef.current) { onClose?.(); @@ -19272,16 +21435,39 @@ function JsonImportDialog$1({ open, value, error, onChange, onClose, onApply }) }, role: "presentation" }, - /* @__PURE__ */ React.createElement("div", { className: "w-full max-w-3xl overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]" }, /* @__PURE__ */ React.createElement("div", { className: "border-b border-white/[0.06] bg-white/[0.02] px-6 py-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35" }, "Structured Import"), /* @__PURE__ */ React.createElement("h3", { className: "mt-2 text-lg font-semibold text-white" }, "Paste lesson JSON"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-white/65" }, "Use this to seed the lesson form with structured content before you refine it in the editor.")), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 px-6 py-5 xl:grid-cols-[minmax(0,1fr)_280px]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3" }, /* @__PURE__ */ React.createElement( + /* @__PURE__ */ React.createElement("div", { className: "flex max-h-[calc(100vh-2rem)] w-full max-w-6xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)] sm:max-h-[calc(100vh-3rem)]" }, /* @__PURE__ */ React.createElement("div", { className: "border-b border-white/[0.06] bg-white/[0.02] px-6 py-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35" }, "Structured Import"), /* @__PURE__ */ React.createElement("h3", { className: "mt-2 text-lg font-semibold text-white" }, "Paste lesson JSON"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-white/65" }, "Use this to seed the lesson form with structured content before you refine it in the editor.")), /* @__PURE__ */ React.createElement("div", { className: "min-h-0 flex-1 overflow-y-auto" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 px-6 py-5 xl:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3" }, /* @__PURE__ */ React.createElement( "textarea", { value, onChange: (event) => onChange?.(event.target.value), rows: 16, - placeholder: '{\n "title": "Prompt engineering for cleaner scene direction",\n "excerpt": "Short summary...",\n "content": "

Rich HTML body...

",\n "category": "Prompting",\n "difficulty": "beginner"\n}', - className: "rounded-[24px] border border-white/10 bg-slate-950/80 px-4 py-4 font-mono text-sm leading-6 text-slate-100 outline-none" + placeholder: exampleValue, + className: "min-h-[320px] rounded-[24px] border border-white/10 bg-slate-950/80 px-4 py-4 font-mono text-sm leading-6 text-slate-100 outline-none" } - ), error ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100" }, error) : null), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Accepted keys"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "title, slug, excerpt"), /* @__PURE__ */ React.createElement("p", null, "lesson_number, course_order, series_name"), /* @__PURE__ */ React.createElement("p", null, "content_markdown, markdown, md"), /* @__PURE__ */ React.createElement("p", null, "content, body, html"), /* @__PURE__ */ React.createElement("p", null, "category_id, category_slug, category"), /* @__PURE__ */ React.createElement("p", null, "difficulty, access_level, lesson_type"), /* @__PURE__ */ React.createElement("p", null, "cover_image, cover, cover_url"), /* @__PURE__ */ React.createElement("p", null, "article_cover_image, article_cover, article_cover_url"), /* @__PURE__ */ React.createElement("p", null, "tags"), /* @__PURE__ */ React.createElement("p", null, "video_url"), /* @__PURE__ */ React.createElement("p", null, "reading_minutes, published_at"), /* @__PURE__ */ React.createElement("p", null, "seo_title, seo_description, featured, active")))), /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => onClose?.(), className: "inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white" }, "Cancel"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => onApply?.(), className: "inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110" }, "Apply JSON"))) + ), error ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100" }, error) : null), /* @__PURE__ */ React.createElement("div", { className: "grid content-start gap-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-2" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2", role: "tablist", "aria-label": "Lesson import reference panels" }, [ + { id: "structure", label: "Structure", icon: "fa-brackets-curly" }, + { id: "fields", label: "Fields", icon: "fa-table-columns" }, + { id: "prompt", label: "Prompt", icon: "fa-wand-magic-sparkles" }, + { id: "notes", label: "Notes", icon: "fa-list-check" } + ].map((tab2) => { + const isActive = tab2.id === activeReferenceTab; + return /* @__PURE__ */ React.createElement( + "button", + { + key: tab2.id, + type: "button", + role: "tab", + "aria-selected": isActive, + onClick: () => setActiveReferenceTab(tab2.id), + className: [ + "inline-flex items-center gap-2 rounded-2xl border px-3.5 py-2 text-xs font-semibold uppercase tracking-[0.14em] transition", + isActive ? "border-sky-300/25 bg-sky-300/12 text-sky-100 ring-1 ring-sky-300/20" : "border-white/10 bg-white/[0.03] text-slate-400 hover:border-sky-300/30 hover:bg-sky-300/10 hover:text-white" + ].join(" ") + }, + /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${tab2.icon} text-[10px]` }), + /* @__PURE__ */ React.createElement("span", null, tab2.label) + ); + })), /* @__PURE__ */ React.createElement("div", { className: "mt-3 rounded-[20px] border border-white/10 bg-slate-950/50 p-4 text-sm text-slate-300" }, activeReferenceTab === "structure" ? /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Accepted structure"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: onCopyExample, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]" }, "Copy example")), /* @__PURE__ */ React.createElement("pre", { className: "mt-3 max-h-[360px] overflow-auto rounded-2xl border border-white/10 bg-slate-950/70 p-3 text-xs leading-6 text-slate-300" }, exampleValue)) : null, activeReferenceTab === "fields" ? /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Accepted keys"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 grid gap-3 text-slate-400 sm:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Core"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs leading-6" }, "title, slug, excerpt"), /* @__PURE__ */ React.createElement("p", { className: "text-xs leading-6" }, "lesson_number, course_order, series_name"), /* @__PURE__ */ React.createElement("p", { className: "text-xs leading-6" }, "difficulty, access_level, lesson_type"), /* @__PURE__ */ React.createElement("p", { className: "text-xs leading-6" }, "reading_minutes, published_at")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Body"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs leading-6" }, "content_markdown, markdown, md"), /* @__PURE__ */ React.createElement("p", { className: "text-xs leading-6" }, "content, body, html"), /* @__PURE__ */ React.createElement("p", { className: "text-xs leading-6" }, "tags, video_url")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Taxonomy"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs leading-6" }, "category_id, category_slug, category"), /* @__PURE__ */ React.createElement("p", { className: "text-xs leading-6" }, "featured, active")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Media + SEO"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs leading-6" }, "cover_image, cover, cover_url"), /* @__PURE__ */ React.createElement("p", { className: "text-xs leading-6" }, "article_cover_image, article_cover, article_cover_url"), /* @__PURE__ */ React.createElement("p", { className: "text-xs leading-6" }, "seo_title, seo_description")))) : null, activeReferenceTab === "prompt" ? /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "ChatGPT helper prompt"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: onCopyPrompt, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]" }, "Copy prompt")), /* @__PURE__ */ React.createElement("pre", { className: "mt-3 max-h-[360px] overflow-auto rounded-2xl border border-white/10 bg-slate-950/70 p-3 text-xs leading-6 text-slate-300 whitespace-pre-wrap" }, promptValue)) : null, activeReferenceTab === "notes" ? /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "What gets applied"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "The JSON updates only recognized lesson fields already supported by the editor."), /* @__PURE__ */ React.createElement("p", null, "Markdown import updates both the Markdown source and rendered HTML body."), /* @__PURE__ */ React.createElement("p", null, "Category values can match by id, slug, or visible category name."), /* @__PURE__ */ React.createElement("p", null, "Imported values become editable immediately before you save the lesson."))) : null))))), /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => onClose?.(), className: "inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white" }, "Cancel"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => onApply?.(), className: "inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110" }, "Apply JSON"))) ), document.body ); @@ -19385,10 +21571,19 @@ function LessonEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de const [courseSaveProcessing, setCourseSaveProcessing] = reactExports.useState({}); const revisions = reactExports.useMemo(() => Array.isArray(editorContext.revisions) ? editorContext.revisions : [], [editorContext.revisions]); const [revisionFieldSelections, setRevisionFieldSelections] = reactExports.useState({}); + const [toast, setToast] = reactExports.useState({ id: 0, visible: false, message: "", variant: "success" }); const csrfToken2 = reactExports.useMemo(() => { if (typeof document === "undefined") return ""; return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; }, []); + const showToast = (message, variant = "error") => { + setToast({ + id: Date.now() + Math.random(), + visible: true, + message, + variant + }); + }; const handleMarkdownContentChange = (nextMarkdown) => { const nextHtml = convertLessonMarkdownToHtml(nextMarkdown); reactExports.startTransition(() => { @@ -19401,6 +21596,11 @@ function LessonEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de reactExports.startTransition(() => { form.setData("content", nextHtml); if (form.data.content_source === "markdown") { + if (!lessonMarkdownTurndown) { + form.setData("content_source", "html"); + form.setData("content_markdown", ""); + return; + } form.setData("content_markdown", convertLessonHtmlToMarkdown(nextHtml)); return; } @@ -19444,6 +21644,59 @@ function LessonEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de const next = categories.map((category) => ({ value: String(category.id), label: category.name })); return [{ value: "", label: "No category" }, ...next]; }, [categories]); + const selectedCategoryName = reactExports.useMemo(() => { + const selectedId = String(form.data.category_id || "").trim(); + if (!selectedId) return ""; + const match = categories.find((category) => String(category.id) === selectedId); + return match ? String(match.name || "") : ""; + }, [categories, form.data.category_id]); + const jsonImportExampleValue = reactExports.useMemo(() => buildLessonImportExample({ + title: form.data.title, + excerpt: form.data.excerpt, + difficulty: form.data.difficulty, + accessLevel: form.data.access_level, + lessonType: form.data.lesson_type, + categoryName: selectedCategoryName + }), [form.data.access_level, form.data.difficulty, form.data.excerpt, form.data.lesson_type, form.data.title, selectedCategoryName]); + const jsonImportPromptValue = reactExports.useMemo(() => buildLessonImportPrompt({ + title: form.data.title, + difficulty: form.data.difficulty, + accessLevel: form.data.access_level, + lessonType: form.data.lesson_type, + categoryName: selectedCategoryName + }), [form.data.access_level, form.data.difficulty, form.data.lesson_type, form.data.title, selectedCategoryName]); + const selectedCourseName = reactExports.useMemo(() => selectedCourses[0]?.label || "Unassigned", [selectedCourses]); + const lessonNumberValue = reactExports.useMemo(() => { + const numeric = Number(form.data.lesson_number); + if (Number.isFinite(numeric) && numeric > 0) return String(numeric); + const suggested = Number(numberingContext?.lesson_number?.suggested || 0); + if (Number.isFinite(suggested) && suggested > 0) return String(suggested); + return "1"; + }, [form.data.lesson_number, numberingContext]); + const lessonHeroPromptValue = reactExports.useMemo(() => buildLessonHeroPrompt({ + title: form.data.title, + excerpt: form.data.excerpt, + categoryName: selectedCategoryName, + tags: String(form.data.tags || "").split(",").map((tag) => tag.trim()).filter(Boolean) + }), [form.data.excerpt, form.data.tags, form.data.title, selectedCategoryName]); + const lessonArticleCoverPromptValue = reactExports.useMemo(() => buildLessonArticleCoverPrompt({ + courseName: selectedCourseName, + lessonNumber: lessonNumberValue, + title: form.data.title, + excerpt: form.data.excerpt, + categoryName: selectedCategoryName, + tags: String(form.data.tags || "").split(",").map((tag) => tag.trim()).filter(Boolean), + aspectRatio: "3:2", + mainVisualSubject: `A premium editorial visual focused on ${String(form.data.title || "this lesson")}`, + previewImageDescription: `A cinematic article-cover scene that clearly supports ${String(form.data.title || "the lesson topic")} and feels premium at thumbnail size.` + }), [form.data.excerpt, form.data.tags, form.data.title, lessonNumberValue, selectedCategoryName, selectedCourseName]); + const lessonHeaderNumberLabel = reactExports.useMemo(() => { + const numeric = Number(form.data.lesson_number); + if (!Number.isFinite(numeric) || numeric < 1) { + return "Unnumbered"; + } + return `Lesson ${String(numeric).padStart(2, "0")}`; + }, [form.data.lesson_number]); reactExports.useEffect(() => { if (method !== "post" || lessonNumberAutofillRef.current) return; if (String(form.data.lesson_number || "").trim() !== "") return; @@ -19515,11 +21768,22 @@ function LessonEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de event.preventDefault(); const payload = buildLessonPayload(form.data); form.transform(() => payload); + const submitOptions = { + preserveScroll: true, + onError: (errors) => { + const nextTab = firstLessonErrorTab(errors); + if (nextTab) { + setActiveTab(nextTab); + } + showToast(firstErrorMessage$2(errors), "error"); + }, + onFinish: () => form.transform((data) => data) + }; if (method === "patch") { - form.patch(submitUrl); + form.patch(submitUrl, submitOptions); return; } - form.post(submitUrl); + form.post(submitUrl, submitOptions); }; const deleteLesson = () => { if (!destroyUrl) return; @@ -19605,6 +21869,18 @@ function LessonEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de setMarkdownImportError(""); setMarkdownImportOpen(false); }; + const copyImportHelperText = async (text2, successMessage) => { + if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { + showToast("Clipboard copy is not available in this browser.", "error"); + return; + } + try { + await navigator.clipboard.writeText(String(text2 || "")); + showToast(successMessage, "success"); + } catch { + showToast("Could not copy import helper text.", "error"); + } + }; const createCategory = async () => { setCategorySaving(true); setCategoryError(""); @@ -19700,7 +21976,7 @@ function LessonEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de if (!window.confirm(message)) return; At.post(revision.restore_url, field ? { field } : {}, { preserveScroll: true }); }; - return /* @__PURE__ */ React.createElement(AdminLayout, { title, subtitle }, /* @__PURE__ */ React.createElement(Se$1, { title: `Admin · ${title}` }), isEditorFullHeight ? /* @__PURE__ */ React.createElement("div", { className: "fixed inset-0 z-[110] bg-[#02040add]/90 backdrop-blur-md", "aria-hidden": "true" }) : null, /* @__PURE__ */ React.createElement("form", { onSubmit: submit, className: "space-y-6 pb-16" }, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.94))] shadow-[0_24px_70px_rgba(2,6,23,0.34)] backdrop-blur" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-4 border-b border-white/10 px-5 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400" }, /* @__PURE__ */ React.createElement(xe, { href: indexUrl, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white transition hover:bg-white/[0.08]" }, "Back to lessons"), /* @__PURE__ */ React.createElement("span", null, destroyUrl ? "Edit lesson" : "New lesson")), /* @__PURE__ */ React.createElement("h1", { className: "mt-3 text-3xl font-semibold tracking-[-0.05em] text-white" }, form.data.title || "Untitled academy lesson"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-3xl text-sm leading-7 text-slate-300" }, "Use the same richer writing flow as the newsroom: drag in the cover, shape the article with the rich editor, and keep publishing details in the same place.")), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => setJsonImportOpen(true), className: "rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, "Import JSON"), /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: form.processing, className: "rounded-2xl border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-sm font-semibold text-sky-100" }, form.processing ? "Saving..." : "Save lesson")))), /* @__PURE__ */ React.createElement(EditorWorkspaceTabs, { tabs: LESSON_EDITOR_TABS, activeTab, onChange: setActiveTab, errorCounts: tabErrorCounts }), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.14)]" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Current workspace"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, activeTabMeta.label), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-2xl text-sm leading-6 text-slate-400" }, activeTabMeta.description)), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 sm:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Words"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, bodyWordCount.toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Excerpt"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, excerptLength, "/800")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Errors"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, Object.keys(form.errors || {}).length))))), /* @__PURE__ */ React.createElement("div", { className: showSupportRail ? "grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start" : "grid gap-6" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0 space-y-6", role: "tabpanel", id: `lesson-editor-panel-${activeTab}`, "aria-labelledby": `lesson-editor-tab-${activeTab}` }, activeTab === "preview" ? /* @__PURE__ */ React.createElement(SectionCard$5, { eyebrow: "Preview mode", title: "Rendered lesson review", description: "Use this tab to scan the public-facing lesson card, article imagery, and rendered article body without the rest of the form in the way.", tone: "feature" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Hero image"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, coverPreviewUrl ? "Ready" : "Missing", " hero artwork for lesson cards and social previews.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Article image"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, articleCoverPreviewUrl ? "Ready" : "Missing", " inline article cover shown before the lesson body.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Body length"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, bodyWordCount.toLocaleString(), " words currently in the lesson body.")))) : null, /* @__PURE__ */ React.createElement(SectionCard$5, { id: "lesson-story-setup", eyebrow: "Story setup", title: "Headline and framing", description: "Start with the lesson identity and summary, then move into the full article body.", tone: "feature", className: sectionClassName("lesson-story-setup") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement( + return /* @__PURE__ */ React.createElement(AdminLayout, { title, subtitle }, /* @__PURE__ */ React.createElement(Se$1, { title: `Admin · ${title}` }), isEditorFullHeight ? /* @__PURE__ */ React.createElement("div", { className: "fixed inset-0 z-[110] bg-[#02040add]/90 backdrop-blur-md", "aria-hidden": "true" }) : null, /* @__PURE__ */ React.createElement("form", { onSubmit: submit, className: "space-y-6 pb-16" }, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.94))] shadow-[0_24px_70px_rgba(2,6,23,0.34)] backdrop-blur" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-4 border-b border-white/10 px-5 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400" }, /* @__PURE__ */ React.createElement(xe, { href: indexUrl, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white transition hover:bg-white/[0.08]" }, "Back to lessons"), /* @__PURE__ */ React.createElement("span", null, destroyUrl ? "Edit lesson" : "New lesson"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-sky-100" }, lessonHeaderNumberLabel)), /* @__PURE__ */ React.createElement("h1", { className: "mt-3 text-3xl font-semibold tracking-[-0.05em] text-white" }, form.data.title || "Untitled academy lesson"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-3xl text-sm leading-7 text-slate-300" }, "Use the same richer writing flow as the newsroom: drag in the cover, shape the article with the rich editor, and keep publishing details in the same place.")), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => setJsonImportOpen(true), className: "rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, "Import JSON"), /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: form.processing, className: "rounded-2xl border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-sm font-semibold text-sky-100" }, form.processing ? "Saving..." : "Save lesson")))), /* @__PURE__ */ React.createElement(EditorWorkspaceTabs, { tabs: LESSON_EDITOR_TABS, activeTab, onChange: setActiveTab, errorCounts: tabErrorCounts }), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.14)]" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Current workspace"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, activeTabMeta.label), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-2xl text-sm leading-6 text-slate-400" }, activeTabMeta.description)), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 sm:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Words"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, bodyWordCount.toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Excerpt"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, excerptLength, "/800")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Errors"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, Object.keys(form.errors || {}).length))))), /* @__PURE__ */ React.createElement("div", { className: showSupportRail ? "grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start" : "grid gap-6" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0 space-y-6", role: "tabpanel", id: `lesson-editor-panel-${activeTab}`, "aria-labelledby": `lesson-editor-tab-${activeTab}` }, activeTab === "preview" ? /* @__PURE__ */ React.createElement(SectionCard$5, { eyebrow: "Preview mode", title: "Rendered lesson review", description: "Use this tab to scan the public-facing lesson card, article imagery, and rendered article body without the rest of the form in the way.", tone: "feature" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Hero image"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, coverPreviewUrl ? "Ready" : "Missing", " hero artwork for lesson cards and social previews.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Article image"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, articleCoverPreviewUrl ? "Ready" : "Missing", " inline article cover shown before the lesson body.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Body length"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, bodyWordCount.toLocaleString(), " words currently in the lesson body.")))) : null, /* @__PURE__ */ React.createElement(SectionCard$5, { id: "lesson-story-setup", eyebrow: "Story setup", title: "Headline and framing", description: "Start with the lesson identity and summary, then move into the full article body.", tone: "feature", className: sectionClassName("lesson-story-setup") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement( TextField$2, { label: "Title", @@ -20033,15 +22309,15 @@ function LessonEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de /* @__PURE__ */ React.createElement("div", { className: "min-w-0" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300" }, formatCourseStep(lesson.order_num) || `#${lesson.display_order}`), lesson.formatted_lesson_number ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300" }, lesson.formatted_lesson_number) : null, lesson.section_title ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300" }, lesson.section_title) : null, lesson.is_current ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-[#f39a24]/25 bg-[#f39a24]/12 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-[#ffd5cd]" }, "This lesson") : null), /* @__PURE__ */ React.createElement("p", { className: "mt-2 truncate text-sm font-semibold text-white" }, lesson.title)), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => updateCourseDraft(course.value, moveCourseManagerLesson(draftLessons, lesson.id, -1)), disabled: lessonIndex === 0, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-40" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-up" })), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => updateCourseDraft(course.value, moveCourseManagerLesson(draftLessons, lesson.id, 1)), disabled: lessonIndex === draftLessons.length - 1, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-40" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-down" })), lesson.edit_url ? /* @__PURE__ */ React.createElement("a", { href: lesson.edit_url, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white" }, "Open lesson") : null) )))); - }))), /* @__PURE__ */ React.createElement(SectionCard$5, { id: "lesson-publishing", eyebrow: "Publishing", title: "Placement and visibility", description: "Set the lesson metadata, schedule, and discovery fields before it goes live.", className: sectionClassName("lesson-publishing") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement(NovaSelect, { label: "Difficulty", value: form.data.difficulty || "", onChange: (nextValue) => form.setData("difficulty", String(nextValue || "")), options: difficultyField?.options || [], searchable: false, className: "bg-black/20", error: form.errors.difficulty })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement(NovaSelect, { label: "Access", value: form.data.access_level || "", onChange: (nextValue) => form.setData("access_level", String(nextValue || "")), options: accessField?.options || [], searchable: false, className: "bg-black/20", error: form.errors.access_level }))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextField$2, { label: "Lesson type", value: form.data.lesson_type, onChange: (event) => form.setData("lesson_type", event.target.value), error: form.errors.lesson_type, placeholder: "article, video, walkthrough" }), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2" }, /* @__PURE__ */ React.createElement(TextField$2, { label: "Microtags", value: form.data.tags, onChange: (event) => form.setData("tags", event.target.value), error: form.errors.tags, placeholder: "workflow, cleanup, publishing" }), /* @__PURE__ */ React.createElement("p", { className: "text-xs leading-6 text-slate-400" }, "Comma-separated short tags for the public lesson page and article discovery context."))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement(NovaSelect, { label: "Category", value: form.data.category_id || "", onChange: (nextValue) => form.setData("category_id", String(nextValue || "")), options: categoryOptions, searchable: false, className: "bg-black/20", error: form.errors.category_id })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Publish at"), /* @__PURE__ */ React.createElement(DateTimePicker, { value: form.data.published_at || "", onChange: (nextValue) => form.setData("published_at", nextValue || ""), clearable: true, className: "bg-black/20" }), /* @__PURE__ */ React.createElement(FieldError$2, { message: form.errors.published_at }))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(ToggleField$2, { label: "Featured", checked: Boolean(form.data.featured), onChange: (event) => form.setData("featured", event.target.checked), help: "Highlight this lesson in featured academy surfaces.", error: form.errors.featured }), /* @__PURE__ */ React.createElement(ToggleField$2, { label: "Active", checked: Boolean(form.data.active), onChange: (event) => form.setData("active", event.target.checked), help: "Keep inactive lessons hidden until the draft is ready.", error: form.errors.active }))), /* @__PURE__ */ React.createElement(SectionCard$5, { id: "lesson-seo", eyebrow: "SEO", title: "Search metadata", description: "Keep the lesson search-ready without stuffing the headline.", className: sectionClassName("lesson-seo") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextField$2, { label: "SEO title", value: form.data.seo_title, onChange: (event) => form.setData("seo_title", event.target.value), error: form.errors.seo_title, maxLength: 180, placeholder: "Optional search title" }), /* @__PURE__ */ React.createElement(TextField$2, { label: "Video URL", value: form.data.video_url, onChange: (event) => form.setData("video_url", event.target.value), error: form.errors.video_url, placeholder: "Optional lesson video URL" })), /* @__PURE__ */ React.createElement(TextAreaField$1, { label: "SEO description", value: form.data.seo_description, onChange: (event) => form.setData("seo_description", event.target.value), error: form.errors.seo_description, rows: 4, hint: "Keep this tighter than the excerpt and focused on search intent." })), /* @__PURE__ */ React.createElement(SectionCard$5, { id: "lesson-categories", eyebrow: "Lesson categories", title: "Create category inline", description: "Add lesson categories without leaving the writing flow.", className: sectionClassName("lesson-categories"), actions: /* @__PURE__ */ React.createElement("a", { href: editorContext.categoryManageUrl, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Manage all categories") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("input", { value: categoryDraft.name, onChange: (event) => setCategoryDraft((current) => ({ ...current, name: event.target.value })), placeholder: "Category name", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("input", { value: categoryDraft.slug, onChange: (event) => setCategoryDraft((current) => ({ ...current, slug: event.target.value })), placeholder: "Optional slug", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("textarea", { value: categoryDraft.description, onChange: (event) => setCategoryDraft((current) => ({ ...current, description: event.target.value })), rows: 3, placeholder: "Description", className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-3" }, /* @__PURE__ */ React.createElement("input", { type: "number", value: categoryDraft.order_num, min: "0", onChange: (event) => setCategoryDraft((current) => ({ ...current, order_num: event.target.value })), className: "w-28 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("div", { className: "min-w-[260px] flex-1" }, /* @__PURE__ */ React.createElement(ToggleField$2, { label: "Category active", checked: categoryDraft.active, onChange: (event) => setCategoryDraft((current) => ({ ...current, active: event.target.checked })), help: "Inactive categories stay available for cleanup but disappear from regular lesson assignment." })), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => void createCategory(), disabled: categorySaving, className: "rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100" }, categorySaving ? "Creating..." : "Create category")), categoryError ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100" }, categoryError) : null), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3" }, (categories || []).length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-500" }, "No lesson categories yet.") : categories.map((category) => /* @__PURE__ */ React.createElement("div", { key: category.id, className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold text-white" }, category.name), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs uppercase tracking-[0.14em] text-slate-500" }, category.slug), category.description ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, category.description) : null), /* @__PURE__ */ React.createElement("a", { href: category.edit_url, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white" }, "Edit"))))))), /* @__PURE__ */ React.createElement(SectionCard$5, { id: "lesson-cover", eyebrow: "Cover image", title: "Hero asset", description: "Use drag and drop for the lesson image, or paste a direct URL when you already have one.", className: sectionClassName("lesson-cover") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4" }, /* @__PURE__ */ React.createElement( + }))), /* @__PURE__ */ React.createElement(SectionCard$5, { id: "lesson-publishing", eyebrow: "Publishing", title: "Placement and visibility", description: "Set the lesson metadata, schedule, and discovery fields before it goes live.", className: sectionClassName("lesson-publishing") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement(NovaSelect, { label: "Difficulty", value: form.data.difficulty || "", onChange: (nextValue) => form.setData("difficulty", String(nextValue || "")), options: difficultyField?.options || [], searchable: false, className: "bg-black/20", error: form.errors.difficulty })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement(NovaSelect, { label: "Access", value: form.data.access_level || "", onChange: (nextValue) => form.setData("access_level", String(nextValue || "")), options: accessField?.options || [], searchable: false, className: "bg-black/20", error: form.errors.access_level }))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextField$2, { label: "Lesson type", value: form.data.lesson_type, onChange: (event) => form.setData("lesson_type", event.target.value), error: form.errors.lesson_type, placeholder: "article, video, walkthrough" }), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2" }, /* @__PURE__ */ React.createElement(TextField$2, { label: "Microtags", value: form.data.tags, onChange: (event) => form.setData("tags", event.target.value), error: form.errors.tags, placeholder: "workflow, cleanup, publishing" }), /* @__PURE__ */ React.createElement("p", { className: "text-xs leading-6 text-slate-400" }, "Comma-separated short tags for the public lesson page and article discovery context."))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement(NovaSelect, { label: "Category", value: form.data.category_id || "", onChange: (nextValue) => form.setData("category_id", String(nextValue || "")), options: categoryOptions, searchable: false, className: "bg-black/20", error: form.errors.category_id })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Publish at"), /* @__PURE__ */ React.createElement(DateTimePicker, { value: form.data.published_at || "", onChange: (nextValue) => form.setData("published_at", nextValue || ""), clearable: true, className: "bg-black/20" }), /* @__PURE__ */ React.createElement(FieldError$2, { message: form.errors.published_at }))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(ToggleField$2, { label: "Featured", checked: Boolean(form.data.featured), onChange: (event) => form.setData("featured", event.target.checked), help: "Highlight this lesson in featured academy surfaces.", error: form.errors.featured }), /* @__PURE__ */ React.createElement(ToggleField$2, { label: "Active", checked: Boolean(form.data.active), onChange: (event) => form.setData("active", event.target.checked), help: "Keep inactive lessons hidden until the draft is ready.", error: form.errors.active }))), /* @__PURE__ */ React.createElement(SectionCard$5, { id: "lesson-seo", eyebrow: "SEO", title: "Search metadata", description: "Keep the lesson search-ready without stuffing the headline.", className: sectionClassName("lesson-seo") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextField$2, { label: "SEO title", value: form.data.seo_title, onChange: (event) => form.setData("seo_title", event.target.value), error: form.errors.seo_title, maxLength: 180, placeholder: "Optional search title" }), /* @__PURE__ */ React.createElement(TextField$2, { label: "Video URL", value: form.data.video_url, onChange: (event) => form.setData("video_url", event.target.value), error: form.errors.video_url, placeholder: "Optional lesson video URL" })), /* @__PURE__ */ React.createElement(TextAreaField$1, { label: "SEO description", value: form.data.seo_description, onChange: (event) => form.setData("seo_description", event.target.value), error: form.errors.seo_description, rows: 4, hint: "Keep this tighter than the excerpt and focused on search intent." })), /* @__PURE__ */ React.createElement(SectionCard$5, { id: "lesson-cover", eyebrow: "Cover image", title: "Hero asset", description: "Use drag and drop for the lesson image, or paste a direct URL when you already have one.", className: sectionClassName("lesson-cover") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 lg:grid-cols-2 lg:items-start" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4" }, /* @__PURE__ */ React.createElement( WorldMediaUploadField, { - label: "Lesson cover", + label: "Hero cover", slot: "cover", value: form.data.cover_image, previewUrl: coverPreviewUrl, - emptyLabel: "Drop a lesson cover", - helperText: "Upload the hero image directly to object storage. A wide landscape image works best for academy cards, previews, and social sharing.", + emptyLabel: "Drop a hero cover", + helperText: "Upload a wide landscape image for academy cards, previews, and social sharing. Keep it cinematic, readable at small sizes, and free of embedded text.", uploadUrl: editorContext.coverUploadUrl, deleteUrl: editorContext.coverDeleteUrl, onChange: ({ path, url }) => { @@ -20051,15 +22327,26 @@ function LessonEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de }, isTemporaryValue: Boolean(stagedCoverPath) && form.data.cover_image === stagedCoverPath } - ), /* @__PURE__ */ React.createElement(FieldError$2, { message: form.errors.cover_image }), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Advanced cover path or URL"), /* @__PURE__ */ React.createElement("input", { value: form.data.cover_image, onChange: (event) => handleManualCoverChange(event.target.value), placeholder: "Optional external URL or stored object path", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("span", { className: "text-xs leading-5 text-slate-500" }, "Keep this for migrations, imported lessons, or when you already know the exact asset path to use.")))), /* @__PURE__ */ React.createElement(SectionCard$5, { id: "lesson-article-cover", eyebrow: "Article cover", title: "Inline article image", description: "This image is rendered just before the lesson content begins.", className: sectionClassName("lesson-article-cover") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4" }, /* @__PURE__ */ React.createElement( + ), /* @__PURE__ */ React.createElement(FieldError$2, { message: form.errors.cover_image }), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Advanced hero cover path or URL"), /* @__PURE__ */ React.createElement("input", { value: form.data.cover_image, onChange: (event) => handleManualCoverChange(event.target.value), placeholder: "Optional external URL or stored object path", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("span", { className: "text-xs leading-5 text-slate-500" }, "Use this for migrations, imported lessons, or when you already know the exact asset path to use."))), /* @__PURE__ */ React.createElement( + CopyablePromptCard, + { + eyebrow: "ChatGPT prompt", + title: "Copy this for the hero cover", + description: "Paste this into ChatGPT when you want a new hero image for the lesson.", + prompt: lessonHeroPromptValue, + onCopy: () => { + void copyImportHelperText(lessonHeroPromptValue, "Hero cover prompt copied."); + } + } + ))), /* @__PURE__ */ React.createElement(SectionCard$5, { id: "lesson-article-cover", eyebrow: "Article cover", title: "Inline article image", description: "This image is rendered just before the lesson content begins.", className: sectionClassName("lesson-article-cover") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 lg:grid-cols-2 lg:items-start" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4" }, /* @__PURE__ */ React.createElement( WorldMediaUploadField, { - label: "Article cover", + label: "Inline article cover", slot: "cover", value: form.data.article_cover_image, previewUrl: articleCoverPreviewUrl, - emptyLabel: "Drop an article cover", - helperText: "Upload the image that appears above the lesson body. Use a strong wide image that still reads well inside the article column.", + emptyLabel: "Drop an inline article cover", + helperText: "Upload the image that appears above the lesson body. Use a strong landscape image that still reads well inside the article column.", uploadUrl: editorContext.coverUploadUrl, deleteUrl: editorContext.coverDeleteUrl, onChange: ({ path, url }) => { @@ -20069,7 +22356,18 @@ function LessonEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de }, isTemporaryValue: Boolean(stagedArticleCoverPath) && form.data.article_cover_image === stagedArticleCoverPath } - ), /* @__PURE__ */ React.createElement(FieldError$2, { message: form.errors.article_cover_image }), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Advanced article cover path or URL"), /* @__PURE__ */ React.createElement("input", { value: form.data.article_cover_image, onChange: (event) => handleManualArticleCoverChange(event.target.value), placeholder: "Optional external URL or stored object path", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("span", { className: "text-xs leading-5 text-slate-500" }, "Use this when the article image already exists in storage or needs to point to an external source.")))), /* @__PURE__ */ React.createElement(SectionCard$5, { id: "lesson-revisions", eyebrow: "Safety net", title: "Revision history", description: "Each lesson update now saves the previous state first. Restore the full lesson or a single field when something goes wrong.", className: sectionClassName("lesson-revisions") }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-sky-300/18 bg-sky-300/8 p-4 text-sm leading-6 text-slate-300" }, "Restoring from a revision creates another revision first, so you can undo the restore if needed."), revisions.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-black/20 px-4 py-5 text-sm leading-7 text-slate-400" }, "No revisions yet. The first saved update will capture the current lesson state.") : revisions.map((revision) => { + ), /* @__PURE__ */ React.createElement(FieldError$2, { message: form.errors.article_cover_image }), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Advanced inline article cover path or URL"), /* @__PURE__ */ React.createElement("input", { value: form.data.article_cover_image, onChange: (event) => handleManualArticleCoverChange(event.target.value), placeholder: "Optional external URL or stored object path", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("span", { className: "text-xs leading-5 text-slate-500" }, "Use this when the article image already exists in storage or needs to point to an external source."))), /* @__PURE__ */ React.createElement( + CopyablePromptCard, + { + eyebrow: "ChatGPT prompt", + title: "Copy this for the inline article image", + description: "Paste this into ChatGPT when you want a cleaner image that sits above the lesson body.", + prompt: lessonArticleCoverPromptValue, + onCopy: () => { + void copyImportHelperText(lessonArticleCoverPromptValue, "Article cover prompt copied."); + } + } + ))), /* @__PURE__ */ React.createElement(SectionCard$5, { id: "lesson-categories", eyebrow: "Lesson categories", title: "Create category inline", description: "Add lesson categories without leaving the writing flow.", className: sectionClassName("lesson-categories"), actions: /* @__PURE__ */ React.createElement("a", { href: editorContext.categoryManageUrl, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Manage all categories") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("input", { value: categoryDraft.name, onChange: (event) => setCategoryDraft((current) => ({ ...current, name: event.target.value })), placeholder: "Category name", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("input", { value: categoryDraft.slug, onChange: (event) => setCategoryDraft((current) => ({ ...current, slug: event.target.value })), placeholder: "Optional slug", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("textarea", { value: categoryDraft.description, onChange: (event) => setCategoryDraft((current) => ({ ...current, description: event.target.value })), rows: 3, placeholder: "Description", className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-3" }, /* @__PURE__ */ React.createElement("input", { type: "number", value: categoryDraft.order_num, min: "0", onChange: (event) => setCategoryDraft((current) => ({ ...current, order_num: event.target.value })), className: "w-28 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("div", { className: "min-w-[260px] flex-1" }, /* @__PURE__ */ React.createElement(ToggleField$2, { label: "Category active", checked: categoryDraft.active, onChange: (event) => setCategoryDraft((current) => ({ ...current, active: event.target.checked })), help: "Inactive categories stay available for cleanup but disappear from regular lesson assignment." })), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => void createCategory(), disabled: categorySaving, className: "rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100" }, categorySaving ? "Creating..." : "Create category")), categoryError ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100" }, categoryError) : null), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3" }, (categories || []).length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-500" }, "No lesson categories yet.") : categories.map((category) => /* @__PURE__ */ React.createElement("div", { key: category.id, className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold text-white" }, category.name), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs uppercase tracking-[0.14em] text-slate-500" }, category.slug), category.description ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, category.description) : null), /* @__PURE__ */ React.createElement("a", { href: category.edit_url, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white" }, "Edit"))))))), /* @__PURE__ */ React.createElement(SectionCard$5, { id: "lesson-revisions", eyebrow: "Safety net", title: "Revision history", description: "Each lesson update now saves the previous state first. Restore the full lesson or a single field when something goes wrong.", className: sectionClassName("lesson-revisions") }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-sky-300/18 bg-sky-300/8 p-4 text-sm leading-6 text-slate-300" }, "Restoring from a revision creates another revision first, so you can undo the restore if needed."), revisions.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-black/20 px-4 py-5 text-sm leading-7 text-slate-400" }, "No revisions yet. The first saved update will capture the current lesson state.") : revisions.map((revision) => { const selectedField = String(revisionFieldSelections[revision.id] || "content"); return /* @__PURE__ */ React.createElement("div", { key: revision.id, className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Revision #", revision.id), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, revision.created_label || "Recently saved", " by ", revision.actor_name || "Staff"), revision.change_note ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs leading-5 text-slate-400" }, revision.change_note) : null), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => restoreLessonRevision(revision), className: "rounded-full border border-[#f39a24]/25 bg-[#f39a24]/12 px-3 py-1.5 text-xs font-semibold text-[#ffd5cd]" }, "Restore full lesson")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 rounded-[20px] border border-white/10 bg-white/[0.03] p-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-white" }, revision.snapshot?.title || "Untitled lesson snapshot"), revision.snapshot?.excerpt ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs leading-5 text-slate-400" }, revision.snapshot.excerpt) : null, revision.snapshot?.content_preview ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs leading-5 text-slate-500" }, revision.snapshot.content_preview) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex flex-wrap gap-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-black/20 px-2.5 py-1" }, revision.snapshot?.course_count || 0, " courses"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-black/20 px-2.5 py-1" }, revision.snapshot?.block_count || 0, " blocks"))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3" }, /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Restore single field"), /* @__PURE__ */ React.createElement("select", { value: selectedField, onChange: (event) => setRevisionFieldSelections((current) => ({ ...current, [revision.id]: event.target.value })), className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }, LESSON_REVISION_FIELD_OPTIONS.map((option) => /* @__PURE__ */ React.createElement("option", { key: option.value, value: option.value }, option.label)))), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => restoreLessonRevision(revision, selectedField), className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, "Restore selected field"))); })))), showSupportRail ? /* @__PURE__ */ React.createElement("div", { className: "space-y-6 xl:sticky xl:top-6 xl:self-start" }, showWriteCompanion ? /* @__PURE__ */ React.createElement(SectionCard$5, { eyebrow: "Writing flow", title: "Author companion", description: "Keep the lesson opening tight, then expand through headings and examples. This panel stays compact so the editor remains the focus." }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-sky-300/18 bg-sky-300/8 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80" }, "Public path"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 break-all text-sm font-semibold text-white" }, lessonPathPreview), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, "Keep the headline specific enough that the slug reads clearly in search results and internal links.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Writing checklist"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 grid gap-2" }, lessonStatusItems.map((item) => /* @__PURE__ */ React.createElement("div", { key: item.label, className: "flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", null, item.label), /* @__PURE__ */ React.createElement("span", { className: `rounded-full px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.14em] ${item.ready ? "border border-emerald-300/20 bg-emerald-300/10 text-emerald-100" : "border border-amber-300/20 bg-amber-300/10 text-amber-100"}` }, item.ready ? "Ready" : "Missing"))))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Article rhythm"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-3 text-sm leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "Lead with the problem in the first paragraph, then break the workflow into headline-sized steps."), /* @__PURE__ */ React.createElement("p", null, "Use Markdown import when you already have a draft, then switch back to the visual editor for structure, media, and cleanup."), /* @__PURE__ */ React.createElement("p", null, "Open full-height mode once the outline is stable so the body editor takes the entire screen."))))) : null, /* @__PURE__ */ React.createElement(SectionCard$5, { id: "lesson-preview", eyebrow: "Preview", title: "Lesson snapshot", description: "A quick view of what editors and visitors will scan first.", className: sectionClassName("lesson-preview") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4" }, /* @__PURE__ */ React.createElement("div", { className: "overflow-hidden rounded-[24px] border border-white/10 bg-black/30" }, coverPreviewUrl ? /* @__PURE__ */ React.createElement("img", { src: coverPreviewUrl, alt: "Lesson cover preview", className: "h-56 w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-56 items-center justify-center px-6 text-center text-sm text-slate-500" }, "No hero cover image selected yet.")), /* @__PURE__ */ React.createElement("div", { className: "overflow-hidden rounded-[24px] border border-white/10 bg-black/30" }, articleCoverPreviewUrl ? /* @__PURE__ */ React.createElement("img", { src: articleCoverPreviewUrl, alt: "Lesson article cover preview", className: "h-56 w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-56 items-center justify-center px-6 text-center text-sm text-slate-500" }, "No article cover image selected yet."))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Lesson summary"), /* @__PURE__ */ React.createElement("h3", { className: "mt-3 text-2xl font-semibold tracking-[-0.04em] text-white" }, form.data.title || "Untitled lesson"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-7 text-slate-400" }, form.data.excerpt || "Add a concise excerpt to frame the lesson before someone opens it."), /* @__PURE__ */ React.createElement("dl", { className: "mt-4 grid grid-cols-2 gap-3 text-xs text-slate-400" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2" }, /* @__PURE__ */ React.createElement("dt", { className: "uppercase tracking-[0.16em] text-slate-500" }, "Difficulty"), /* @__PURE__ */ React.createElement("dd", { className: "mt-1 text-sm text-white" }, form.data.difficulty || "—")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2" }, /* @__PURE__ */ React.createElement("dt", { className: "uppercase tracking-[0.16em] text-slate-500" }, "Access"), /* @__PURE__ */ React.createElement("dd", { className: "mt-1 text-sm text-white" }, form.data.access_level || "—")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2" }, /* @__PURE__ */ React.createElement("dt", { className: "uppercase tracking-[0.16em] text-slate-500" }, "Reading"), /* @__PURE__ */ React.createElement("dd", { className: "mt-1 text-sm text-white" }, form.data.reading_minutes || "—", " min")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2" }, /* @__PURE__ */ React.createElement("dt", { className: "uppercase tracking-[0.16em] text-slate-500" }, "Body"), /* @__PURE__ */ React.createElement("dd", { className: "mt-1 text-sm text-white" }, bodyWordCount.toLocaleString(), " words")))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Article preview"), deferredArticlePreviewHtml ? /* @__PURE__ */ React.createElement( @@ -20084,12 +22382,20 @@ function LessonEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de open: jsonImportOpen, value: jsonImportValue, error: jsonImportError, + exampleValue: jsonImportExampleValue, + promptValue: jsonImportPromptValue, onChange: (nextValue) => { setJsonImportValue(nextValue); if (jsonImportError) { setJsonImportError(""); } }, + onCopyExample: () => { + void copyImportHelperText(jsonImportExampleValue, "Lesson JSON example copied."); + }, + onCopyPrompt: () => { + void copyImportHelperText(jsonImportPromptValue, "Lesson import prompt copied."); + }, onClose: () => { setJsonImportOpen(false); setJsonImportError(""); @@ -20114,9 +22420,19 @@ function LessonEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de }, onApply: applyMarkdownImport } + ), /* @__PURE__ */ React.createElement( + ShareToast, + { + key: toast.id, + message: toast.message, + visible: toast.visible, + variant: toast.variant, + duration: toast.variant === "error" ? 3200 : 2200, + onHide: () => setToast((current) => ({ ...current, visible: false })) + } )); } -const __vite_glob_0_12 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_22 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: LessonEditor }, Symbol.toStringTag, { value: "Module" })); @@ -20127,18 +22443,54 @@ function normalizePayload(fields, data) { payload[field.name] = String(payload[field.name] || "").split(/[,\n]/).map((item) => item.trim()).filter(Boolean); } if (field.type === "json") { - try { - payload[field.name] = payload[field.name] ? JSON.parse(payload[field.name]) : {}; - } catch { - payload[field.name] = {}; + if (typeof payload[field.name] === "string") { + const trimmed = payload[field.name].trim(); + if (!trimmed) { + payload[field.name] = null; + return; + } + try { + payload[field.name] = JSON.parse(trimmed); + } catch { + } } } }); return payload; } +function serializeStructuredJson(value) { + if (value == null || value === "") return ""; + if (typeof value === "string") return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return ""; + } +} function getField(fields, name2) { return fields.find((field) => field.name === name2) || null; } +function firstErrorMessage$1(errors, fallback = "Please correct the highlighted fields and try again.") { + const queue = [errors]; + while (queue.length > 0) { + const current = queue.shift(); + if (typeof current === "string") { + const message = current.trim(); + if (message) { + return message; + } + continue; + } + if (Array.isArray(current)) { + queue.push(...current); + continue; + } + if (current && typeof current === "object") { + queue.push(...Object.values(current)); + } + } + return fallback; +} function SectionCard$4({ eyebrow, title, description, children, className = "" }) { return /* @__PURE__ */ React.createElement("section", { className: `w-full min-w-0 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_20px_80px_rgba(15,23,42,0.18)] ${className}`.trim() }, /* @__PURE__ */ React.createElement("div", { className: "mb-5" }, eyebrow ? /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80" }, eyebrow) : null, /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-xl font-semibold tracking-[-0.04em] text-white" }, title), description ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-7 text-slate-400" }, description) : null), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5" }, children)); } @@ -20166,6 +22518,13 @@ const PROMPT_EDITOR_TABS = [ icon: "fa-wand-magic-sparkles", sections: ["prompt-body"] }, + { + id: "advanced", + label: "Advanced Docs", + description: "Store structured documentation, placeholders, helper prompts, and prompt variants without burying them in plain text notes.", + icon: "fa-layer-group", + sections: ["prompt-advanced"] + }, { id: "comparisons", label: "AI Model Comparisons", @@ -20201,6 +22560,10 @@ const PROMPT_FIELD_TAB_MAP = { negative_prompt: "prompt", usage_notes: "prompt", workflow_notes: "prompt", + documentation: "advanced", + placeholders: "advanced", + helper_prompts: "advanced", + prompt_variants: "advanced", preview_image: "media", preview_image_file: "media", published_at: "publish", @@ -20223,6 +22586,7 @@ function countPlainWords(value) { function emptyPromptComparison() { return { client_key: `comparison-${Math.random().toString(36).slice(2, 10)}`, + display_type: "", provider: "", model_name: "", notes: "", @@ -20244,6 +22608,7 @@ function sanitizePromptComparison(value) { } return { client_key: String(value.client_key || emptyPromptComparison().client_key), + display_type: String(value.display_type || "").trim(), provider: String(value.provider || "").trim(), model_name: String(value.model_name || "").trim(), notes: String(value.notes || "").trim(), @@ -20269,6 +22634,7 @@ function normalizePromptComparisons(value, { preserveEmpty = false } = {}) { if (!item || typeof item !== "object") return preserveEmpty ? emptyPromptComparison() : null; const normalized = sanitizePromptComparison(item); const hasContent = [ + normalized.display_type, normalized.provider, normalized.model_name, normalized.notes, @@ -20285,6 +22651,7 @@ function normalizePromptComparisons(value, { preserveEmpty = false } = {}) { } function serializePromptComparisons(value) { return normalizePromptComparisons(value).map((comparison) => ({ + display_type: comparison.display_type, provider: comparison.provider, model_name: comparison.model_name, notes: comparison.notes, @@ -20298,6 +22665,31 @@ function serializePromptComparisons(value) { active: Boolean(comparison.active) })); } +const PROMPT_COMPARISON_TYPE_OPTIONS = [ + { value: "", label: "Default" }, + { value: "Comparison", label: "Comparison" }, + { value: "Variation", label: "Variation" }, + { value: "Iteration", label: "Iteration" }, + { value: "Refinement", label: "Refinement" }, + { value: "Remix", label: "Remix" } +]; +const PROMPT_COMPARISON_EDITOR_TABS = [ + { + id: "summary", + label: "Summary", + description: "Keep the main comparison details visible while editing this block." + }, + { + id: "setup", + label: "Setup", + description: "Store generation settings and workflow context for this model output." + }, + { + id: "review", + label: "Review", + description: "Capture strengths and weaknesses without crowding the main editor view." + } +]; function normalizeCodeList(values) { return Array.from(new Set((Array.isArray(values) ? values : []).map((value) => String(value || "").trim()).filter(Boolean))); } @@ -20341,6 +22733,10 @@ function parsePromptImport(rawText, categoryOptions) { if (parsed.negative_prompt != null) apply("negative_prompt", String(parsed.negative_prompt)); if (parsed.usage_notes != null) apply("usage_notes", String(parsed.usage_notes)); if (parsed.workflow_notes != null) apply("workflow_notes", String(parsed.workflow_notes)); + if (parsed.documentation != null) apply("documentation", serializeStructuredJson(parsed.documentation)); + if (parsed.placeholders != null) apply("placeholders", serializeStructuredJson(parsed.placeholders)); + if (parsed.helper_prompts != null) apply("helper_prompts", serializeStructuredJson(parsed.helper_prompts)); + if (parsed.prompt_variants != null) apply("prompt_variants", serializeStructuredJson(parsed.prompt_variants)); if (parsed.preview_image != null) apply("preview_image", String(parsed.preview_image)); if (parsed.preview_image_url != null && parsed.preview_image == null) apply("preview_image", String(parsed.preview_image_url)); if (parsed.published_at != null) apply("published_at", String(parsed.published_at)); @@ -20365,7 +22761,7 @@ function parsePromptImport(rawText, categoryOptions) { } return { next, applied }; } -function getCsrfToken$g() { +function getCsrfToken$i() { if (typeof document === "undefined") return ""; return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } @@ -20376,7 +22772,7 @@ async function uploadPromptComparisonMedia(uploadUrl, file) { const response = await fetch(uploadUrl, { method: "POST", headers: { - "X-CSRF-TOKEN": getCsrfToken$g(), + "X-CSRF-TOKEN": getCsrfToken$i(), Accept: "application/json" }, credentials: "same-origin", @@ -20394,13 +22790,20 @@ async function deletePromptComparisonMedia(deleteUrl, path) { method: "DELETE", headers: { "Content-Type": "application/json", - "X-CSRF-TOKEN": getCsrfToken$g(), + "X-CSRF-TOKEN": getCsrfToken$i(), Accept: "application/json" }, credentials: "same-origin", body: JSON.stringify({ path }) }); } +function isSupportedPromptComparisonImage(file) { + if (!(file instanceof File)) return false; + if (["image/jpeg", "image/png", "image/webp"].includes(file.type)) { + return true; + } + return /\.(jpe?g|png|webp)$/i.test(String(file.name || "")); +} function CodeListEditor({ title, description, items, customItems, draftValue, setDraftValue, onAdd, onRemove }) { return /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, title), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-300" }, description), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, items.map((item) => { const removable = customItems.includes(item); @@ -20430,6 +22833,50 @@ function PromptJsonImportDialog({ open, value, error, onChange, onClose, onApply negative_prompt: "blurry, muddy lighting, distorted tree trunks, low detail, oversaturated highlights", usage_notes: "Start with the base prompt, then increase atmosphere and foliage density gradually.", workflow_notes: "Good candidate for comparison across ChatGPT, Gemini, and Leonardo image models.", + documentation: { + summary: "Create a city wallpaper that blends landmark imagery with climate context.", + best_for: ["travel wallpapers", "editorial posters"], + how_to_use: ["Choose a city", "Collect climate data", "Insert the placeholders", "Generate and review the final image"], + required_inputs: ["City name", "Country", "Landmarks", "Monthly weather data"], + workflow: ["Research", "Prompt preparation", "Image generation"], + tips: ["Keep the climate ribbon subtle and secondary to the city artwork."], + common_mistakes: ["Inventing weather data"], + data_accuracy_notes: ["Use long-term averages where available."], + display_notes: "Use the image-safe variant when your image model struggles with text." + }, + placeholders: [ + { + key: "CITY_NAME", + label: "City name", + description: "The city featured in the final image.", + required: true, + example: "Paris", + type: "text" + } + ], + helper_prompts: [ + { + title: "Collect city climate data", + type: "data_collection", + description: "Gather landmark and climate references before using the main prompt.", + prompt: "Collect city and climate data for [CITY_NAME].", + expected_output: "json", + active: true + } + ], + prompt_variants: [ + { + title: "Image-safe version", + slug: "image-safe-version", + description: "Reduced text pressure for image models.", + prompt: "Create an image-safe city climate portrait.", + negative_prompt: "tiny text, clutter", + recommended: true, + recommended_for: ["general image generation"], + risk_notes: ["Climate icons may still be abstract"], + active: true + } + ], preview_image: "https://files.skinbase.org/prompts/peaceful-fantasy-forest.webp", featured: false, prompt_of_week: false, @@ -20465,6 +22912,10 @@ Recommended fields: - negative_prompt: optional exclusions - usage_notes: practical usage guidance - workflow_notes: internal/editorial workflow notes +- documentation: object with structured guidance for how to use the prompt +- placeholders: array of prompt variable objects +- helper_prompts: array of supporting prompts used before or after the main prompt +- prompt_variants: array of alternative prompt versions - preview_image: path or URL - featured: boolean - prompt_of_week: boolean @@ -20485,6 +22936,25 @@ tool_notes object fields: - score (1-10) - active boolean +helper_prompts object fields: +- title +- type: data_collection|prompt_preparation|refinement|validation|variation|translation|seo|other +- description +- prompt +- expected_output: json|text|markdown|image_prompt +- active boolean + +prompt_variants object fields: +- title +- slug +- description +- prompt +- negative_prompt +- recommended boolean +- recommended_for +- risk_notes +- active boolean + Rules: - Return one JSON object only. - Keep excerpt concise and readable in cards. @@ -20579,7 +23049,7 @@ Source content: placeholder: '{\n "title": "Peaceful Fantasy Forest Wallpaper",\n "excerpt": "Short summary...",\n "prompt": "Main prompt text...",\n "tool_notes": []\n}', className: "nova-scrollbar w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-white outline-none placeholder:text-white/30" } - ), error ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100" }, error) : null), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Recognized keys"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "title, slug, excerpt"), /* @__PURE__ */ React.createElement("p", null, "category_id, category, category_slug"), /* @__PURE__ */ React.createElement("p", null, "difficulty, access_level, aspect_ratio"), /* @__PURE__ */ React.createElement("p", null, "tags"), /* @__PURE__ */ React.createElement("p", null, "prompt, negative_prompt"), /* @__PURE__ */ React.createElement("p", null, "usage_notes, workflow_notes"), /* @__PURE__ */ React.createElement("p", null, "preview_image, preview_image_url"), /* @__PURE__ */ React.createElement("p", null, "published_at, seo_title, seo_description"), /* @__PURE__ */ React.createElement("p", null, "featured, prompt_of_week, active"), /* @__PURE__ */ React.createElement("p", null, "tool_notes, comparisons")))) : null, activeImportTab === "structure" ? /* @__PURE__ */ React.createElement("div", { className: "grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "mb-3 flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Structure example"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => copyText(JSON.stringify(structureExample, null, 2), "Structure example"), className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]" }, "Copy example")), /* @__PURE__ */ React.createElement("pre", { className: "nova-scrollbar max-h-[52vh] overflow-auto rounded-[20px] border border-white/10 bg-slate-950/80 p-4 text-xs leading-6 text-slate-200" }, JSON.stringify(structureExample, null, 2))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Notes"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-3 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "`tool_notes` can be an array of comparison objects or a simpler array under `comparisons`."), /* @__PURE__ */ React.createElement("p", null, "`tags` can be strings or objects with `name`, `label`, `title`, or `slug`."), /* @__PURE__ */ React.createElement("p", null, "`preview_image` accepts either a stored path or an external URL.")))) : null, activeImportTab === "docs" ? /* @__PURE__ */ React.createElement("div", { className: "grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Field guide"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-3 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "title"), " - public prompt name used in the library and detail page."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "excerpt"), " - short summary for cards, preview blocks, and search results."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "prompt"), " - the main instruction body shown to creators."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "negative_prompt"), " - exclusions, defects, or anti-patterns."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "tool_notes"), " - structured comparison notes for provider/model variants."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "preview_image"), " - existing asset URL or stored path. File upload still happens separately."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "category_id"), " is preferred when known. `category` or `category_slug` are used for best-effort matching."))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Import rules"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "Unknown keys are ignored, so broader AI output is safe to paste."), /* @__PURE__ */ React.createElement("p", null, "Use JSON booleans for featured, prompt_of_week, and active."), /* @__PURE__ */ React.createElement("p", null, "Use `YYYY-MM-DD HH:MM:SS` for `published_at` when scheduling is needed."), /* @__PURE__ */ React.createElement("p", null, "Keep comparison rows normalized so provider/model names remain consistent in the frontend.")))) : null, activeImportTab === "prompts" ? /* @__PURE__ */ React.createElement("div", { className: "grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4" }, aiPromptExamples.map((example) => /* @__PURE__ */ React.createElement("div", { key: example.title, className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-200/70" }, example.title), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => copyText(example.prompt, example.title), className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]" }, "Copy prompt")), /* @__PURE__ */ React.createElement("pre", { className: "nova-scrollbar mt-3 max-h-56 overflow-auto whitespace-pre-wrap rounded-[18px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-6 text-slate-200" }, example.prompt)))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Prompt tips"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "Tell the model to return JSON only, with no explanation text."), /* @__PURE__ */ React.createElement("p", null, "Ask for `tool_notes` when you want provider-by-provider comparison output."), /* @__PURE__ */ React.createElement("p", null, "Tell the model to keep titles and tags production-ready, not overly verbose.")))) : null), copyFeedback ? /* @__PURE__ */ React.createElement("div", { className: "px-6 pb-2 text-right text-xs font-medium text-sky-200/80" }, copyFeedback) : null, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => onClose?.(), className: "inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white" }, "Cancel"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => onApply?.(), className: "inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110" }, "Apply JSON"))) + ), error ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100" }, error) : null), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Recognized keys"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "title, slug, excerpt"), /* @__PURE__ */ React.createElement("p", null, "category_id, category, category_slug"), /* @__PURE__ */ React.createElement("p", null, "difficulty, access_level, aspect_ratio"), /* @__PURE__ */ React.createElement("p", null, "tags"), /* @__PURE__ */ React.createElement("p", null, "prompt, negative_prompt"), /* @__PURE__ */ React.createElement("p", null, "usage_notes, workflow_notes"), /* @__PURE__ */ React.createElement("p", null, "documentation, placeholders"), /* @__PURE__ */ React.createElement("p", null, "helper_prompts, prompt_variants"), /* @__PURE__ */ React.createElement("p", null, "preview_image, preview_image_url"), /* @__PURE__ */ React.createElement("p", null, "published_at, seo_title, seo_description"), /* @__PURE__ */ React.createElement("p", null, "featured, prompt_of_week, active"), /* @__PURE__ */ React.createElement("p", null, "tool_notes, comparisons")))) : null, activeImportTab === "structure" ? /* @__PURE__ */ React.createElement("div", { className: "grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "mb-3 flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Structure example"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => copyText(JSON.stringify(structureExample, null, 2), "Structure example"), className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]" }, "Copy example")), /* @__PURE__ */ React.createElement("pre", { className: "nova-scrollbar max-h-[52vh] overflow-auto rounded-[20px] border border-white/10 bg-slate-950/80 p-4 text-xs leading-6 text-slate-200" }, JSON.stringify(structureExample, null, 2))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Notes"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-3 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "`tool_notes` can be an array of comparison objects or a simpler array under `comparisons`."), /* @__PURE__ */ React.createElement("p", null, "`documentation`, `placeholders`, `helper_prompts`, and `prompt_variants` can be nested JSON and are preserved during import."), /* @__PURE__ */ React.createElement("p", null, "`tags` can be strings or objects with `name`, `label`, `title`, or `slug`."), /* @__PURE__ */ React.createElement("p", null, "`preview_image` accepts either a stored path or an external URL.")))) : null, activeImportTab === "docs" ? /* @__PURE__ */ React.createElement("div", { className: "grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Field guide"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-3 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "title"), " - public prompt name used in the library and detail page."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "excerpt"), " - short summary for cards, preview blocks, and search results."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "prompt"), " - the main instruction body shown to creators."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "negative_prompt"), " - exclusions, defects, or anti-patterns."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "documentation"), " - structured user-facing guidance for summary, workflow, tips, and common mistakes."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "placeholders"), " - prompt variables such as `CITY_NAME` or `MONTHLY_WEATHER_DATA`."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "helper_prompts"), " - supporting prompts for data collection, validation, or refinement."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "prompt_variants"), " - alternative versions of the same prompt for safer or model-specific output."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "tool_notes"), " - structured comparison notes for provider/model variants."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "preview_image"), " - existing asset URL or stored path. File upload still happens separately."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "category_id"), " is preferred when known. `category` or `category_slug` are used for best-effort matching."))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Import rules"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "Unknown keys are ignored, so broader AI output is safe to paste."), /* @__PURE__ */ React.createElement("p", null, "Use JSON booleans for featured, prompt_of_week, and active."), /* @__PURE__ */ React.createElement("p", null, "Use `YYYY-MM-DD HH:MM:SS` for `published_at` when scheduling is needed."), /* @__PURE__ */ React.createElement("p", null, "Use `documentation` for longer public guidance, and keep `usage_notes` short and practical."), /* @__PURE__ */ React.createElement("p", null, "Use `helper_prompts` for data collection or validation prompts, and `prompt_variants` for safer or model-specific alternatives."), /* @__PURE__ */ React.createElement("p", null, "Keep comparison rows normalized so provider/model names remain consistent in the frontend.")))) : null, activeImportTab === "prompts" ? /* @__PURE__ */ React.createElement("div", { className: "grid h-full min-h-0 items-start gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "grid min-w-0 gap-4" }, aiPromptExamples.map((example) => /* @__PURE__ */ React.createElement("div", { key: example.title, className: "min-w-0 overflow-hidden rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-200/70" }, example.title), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => copyText(example.prompt, example.title), className: "shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]" }, "Copy prompt")), /* @__PURE__ */ React.createElement("pre", { className: "nova-scrollbar mt-3 max-h-56 min-w-0 overflow-auto whitespace-pre-wrap break-words rounded-[18px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-6 text-slate-200 [overflow-wrap:anywhere]" }, example.prompt)))), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 self-start rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Prompt tips"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "Tell the model to return JSON only, with no explanation text."), /* @__PURE__ */ React.createElement("p", null, "Ask for `tool_notes` when you want provider-by-provider comparison output."), /* @__PURE__ */ React.createElement("p", null, "Ask for `documentation`, `placeholders`, `helper_prompts`, and `prompt_variants` only when the prompt needs advanced structure."), /* @__PURE__ */ React.createElement("p", null, "Tell the model to keep titles and tags production-ready, not overly verbose.")))) : null), copyFeedback ? /* @__PURE__ */ React.createElement("div", { className: "px-6 pb-2 text-right text-xs font-medium text-sky-200/80" }, copyFeedback) : null, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => onClose?.(), className: "inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white" }, "Cancel"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => onApply?.(), className: "inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110" }, "Apply JSON"))) ), document.body ); @@ -20626,11 +23096,17 @@ function PromptEditorTabs({ activeTab, onChange, errorCounts }) { } function PromptComparisonEditor({ comparisons, setComparisons, editorContext }) { const [busyIndex, setBusyIndex] = reactExports.useState(null); + const [comparisonEditorTabs, setComparisonEditorTabs] = reactExports.useState({}); const [uploadError, setUploadError] = reactExports.useState(""); + const [bulkUploadState, setBulkUploadState] = reactExports.useState(null); + const [bulkComparisonType, setBulkComparisonType] = reactExports.useState(""); + const [isBulkDropActive, setIsBulkDropActive] = reactExports.useState(false); const [draftProvider, setDraftProvider] = reactExports.useState(""); const [draftModel, setDraftModel] = reactExports.useState(""); const [customProviders, setCustomProviders] = reactExports.useState([]); const [customModels, setCustomModels] = reactExports.useState([]); + const bulkFileInputRef = reactExports.useRef(null); + const comparisonsRef = reactExports.useRef(Array.isArray(comparisons) ? comparisons : []); const providerStorageKey = "academy.prompt-comparison.providers"; const modelStorageKey = "academy.prompt-comparison.models"; const defaultProviders = normalizeCodeList(editorContext?.comparisonCodeLists?.providers || []); @@ -20641,11 +23117,24 @@ function PromptComparisonEditor({ comparisons, setComparisons, editorContext }) setCustomProviders(loadCodeList(providerStorageKey)); setCustomModels(loadCodeList(modelStorageKey)); }, []); + reactExports.useEffect(() => { + comparisonsRef.current = Array.isArray(comparisons) ? comparisons : []; + }, [comparisons]); + const commitComparisons = (nextComparisons) => { + const normalized = normalizePromptComparisons(nextComparisons, { preserveEmpty: true }); + comparisonsRef.current = normalized; + setComparisons(normalized); + return normalized; + }; const replaceComparison = (index2, nextComparison) => { - setComparisons(comparisons.map((comparison, currentIndex) => currentIndex === index2 ? sanitizePromptComparison(nextComparison) : comparison)); + const currentComparisons = comparisonsRef.current; + if (index2 < 0 || index2 >= currentComparisons.length) return; + commitComparisons(currentComparisons.map((comparison, currentIndex) => currentIndex === index2 ? sanitizePromptComparison(nextComparison) : comparison)); }; const updateComparison = (index2, field, value) => { - replaceComparison(index2, { ...comparisons[index2], [field]: value }); + const currentComparison = comparisonsRef.current[index2]; + if (!currentComparison) return; + replaceComparison(index2, { ...currentComparison, [field]: value }); }; const removeStoredMedia = async (comparison) => { const deleteUrl = editorContext?.comparisonMediaDeleteUrl || ""; @@ -20653,8 +23142,10 @@ function PromptComparisonEditor({ comparisons, setComparisons, editorContext }) await Promise.all(imagePaths.map((path) => deletePromptComparisonMedia(deleteUrl, path))); }; const removeComparison = async (index2) => { - const comparison = comparisons[index2]; - setComparisons(comparisons.filter((_2, currentIndex) => currentIndex !== index2)); + const currentComparisons = comparisonsRef.current; + const comparison = currentComparisons[index2]; + if (!comparison) return; + commitComparisons(currentComparisons.filter((_2, currentIndex) => currentIndex !== index2)); try { await removeStoredMedia(comparison); } catch { @@ -20662,13 +23153,21 @@ function PromptComparisonEditor({ comparisons, setComparisons, editorContext }) }; const moveComparison = (index2, direction) => { const nextIndex = index2 + direction; - if (nextIndex < 0 || nextIndex >= comparisons.length) return; - const nextComparisons = [...comparisons]; + const currentComparisons = comparisonsRef.current; + if (nextIndex < 0 || nextIndex >= currentComparisons.length) return; + const nextComparisons = [...currentComparisons]; const [entry] = nextComparisons.splice(index2, 1); nextComparisons.splice(nextIndex, 0, entry); - setComparisons(nextComparisons); + commitComparisons(nextComparisons); }; - const resolvePreviewUrl = (comparison) => comparison.image_url || comparison.thumb_url || ""; + const resolvePreviewUrl = (comparison) => comparison.thumb_url || comparison.image_url || ""; + const addComparison = () => commitComparisons([ + ...comparisonsRef.current, + sanitizePromptComparison({ + ...emptyPromptComparison(), + display_type: bulkComparisonType + }) + ]); const addCustomProvider = () => { const nextValue = String(draftProvider || "").trim(); if (!nextValue) return; @@ -20695,35 +23194,84 @@ function PromptComparisonEditor({ comparisons, setComparisons, editorContext }) setCustomModels(nextItems); saveCodeList(modelStorageKey, nextItems); }; - const handleUpload = async (index2, file) => { + const uploadComparisonImage = async (index2, file) => { const uploadUrl = editorContext?.comparisonMediaUploadUrl || ""; if (!uploadUrl || !file) return; + const previous2 = comparisonsRef.current[index2]; + if (!previous2) return; setBusyIndex(index2); - setUploadError(""); - const previous2 = comparisons[index2]; try { const uploaded = await uploadPromptComparisonMedia(uploadUrl, file); + const currentComparison = comparisonsRef.current[index2] || previous2; replaceComparison(index2, { - ...previous2, + ...currentComparison, image_path: uploaded.path || "", image_url: uploaded.url || "", - thumb_path: previous2?.thumb_path || "", - thumb_url: previous2?.thumb_url || "" + thumb_path: uploaded.thumb_path || uploaded.path || "", + thumb_url: uploaded.thumb_url || uploaded.url || "" }); if (previous2?.image_path && previous2.image_path !== uploaded.path) { await deletePromptComparisonMedia(editorContext?.comparisonMediaDeleteUrl || "", previous2.image_path); } - if (previous2?.thumb_path && previous2.thumb_path !== uploaded.path) { + if (previous2?.thumb_path && previous2.thumb_path !== (uploaded.thumb_path || uploaded.path)) { await deletePromptComparisonMedia(editorContext?.comparisonMediaDeleteUrl || "", previous2.thumb_path); } - } catch (error) { - setUploadError(error instanceof Error ? error.message : "Could not upload comparison image."); } finally { setBusyIndex(null); } }; + const handleUpload = async (index2, file) => { + setUploadError(""); + try { + await uploadComparisonImage(index2, file); + } catch (error) { + setUploadError(error instanceof Error ? error.message : "Could not upload comparison image."); + } + }; + const handleBulkUpload = async (fileList) => { + const incomingFiles = Array.from(fileList || []).filter((file) => file instanceof File); + if (incomingFiles.length === 0) return; + const validFiles = incomingFiles.filter((file) => isSupportedPromptComparisonImage(file)); + const invalidFiles = incomingFiles.filter((file) => !isSupportedPromptComparisonImage(file)); + if (validFiles.length === 0) { + setUploadError("Select one or more JPG, PNG, or WebP images to create comparison blocks."); + return; + } + setUploadError(""); + const startIndex = comparisonsRef.current.length; + commitComparisons([ + ...comparisonsRef.current, + ...validFiles.map(() => sanitizePromptComparison({ + ...emptyPromptComparison(), + display_type: bulkComparisonType + })) + ]); + const failedFiles = []; + try { + for (let offset = 0; offset < validFiles.length; offset += 1) { + setBulkUploadState({ current: offset + 1, total: validFiles.length }); + try { + await uploadComparisonImage(startIndex + offset, validFiles[offset]); + } catch { + failedFiles.push(validFiles[offset].name || `Image ${offset + 1}`); + } + } + } finally { + setBulkUploadState(null); + setIsBulkDropActive(false); + } + const notices = []; + if (invalidFiles.length > 0) { + notices.push(`Skipped ${invalidFiles.length} unsupported ${invalidFiles.length === 1 ? "file" : "files"}.`); + } + if (failedFiles.length > 0) { + notices.push(`${failedFiles.length} ${failedFiles.length === 1 ? "image failed" : "images failed"} to upload.`); + } + setUploadError(notices.join(" ")); + }; const clearMedia = async (index2) => { - const comparison = comparisons[index2]; + const comparison = comparisonsRef.current[index2]; + if (!comparison) return; replaceComparison(index2, { ...comparison, image_path: "", @@ -20736,7 +23284,107 @@ function PromptComparisonEditor({ comparisons, setComparisons, editorContext }) } catch { } }; - return /* @__PURE__ */ React.createElement("div", { className: "space-y-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Structured blocks"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-300" }, "Upload the generated output for each provider, then document what it does well, where it fails, and which workflow it fits best.")), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => setComparisons([...comparisons, emptyPromptComparison()]), className: "rounded-full border border-amber-300/25 bg-amber-300/12 px-4 py-2.5 text-sm font-semibold text-amber-100" }, "+ Add AI Comparison")), uploadError ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[20px] border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100" }, uploadError) : null, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 xl:grid-cols-2" }, /* @__PURE__ */ React.createElement( + const setComparisonEditorTab = (comparisonKey, tabId) => { + setComparisonEditorTabs((current) => current?.[comparisonKey] === tabId ? current : { ...current, [comparisonKey]: tabId }); + }; + return /* @__PURE__ */ React.createElement("div", { className: "space-y-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Structured blocks"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-300" }, "Upload the generated output for each provider, then document what it does well, where it fails, and which workflow it fits best.")), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: addComparison, className: "rounded-full border border-amber-300/25 bg-amber-300/12 px-4 py-2.5 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/18" }, "+ Add AI Comparison")), uploadError ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[20px] border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100" }, uploadError) : null, comparisons.length ? comparisons.map((comparison, index2) => { + const comparisonKey = comparison.client_key || `comparison-${index2}`; + const activeComparisonTab = comparisonEditorTabs[comparisonKey] || "summary"; + const activeComparisonTabMeta = PROMPT_COMPARISON_EDITOR_TABS.find((tab2) => tab2.id === activeComparisonTab) || PROMPT_COMPARISON_EDITOR_TABS[0]; + return /* @__PURE__ */ React.createElement("section", { key: comparisonKey, className: "overflow-hidden rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.72),rgba(8,12,20,0.92))] p-5 shadow-[0_24px_70px_rgba(2,6,23,0.28)]" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-200/75" }, "AI model comparison"), /* @__PURE__ */ React.createElement("h3", { className: "mt-2 text-lg font-semibold tracking-[-0.03em] text-white" }, comparison.model_name || `${comparison.display_type || "Comparison"} ${String(index2 + 1).padStart(2, "0")}`), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Document how this model handles the same prompt so creators can choose the right tool faster.")), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => moveComparison(index2, -1), disabled: index2 === 0, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white disabled:opacity-40" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-up" })), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => moveComparison(index2, 1), disabled: index2 === comparisons.length - 1, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white disabled:opacity-40" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-down" })), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => removeComparison(index2), className: "rounded-full border border-rose-300/20 bg-rose-300/10 px-3 py-2 text-xs font-semibold text-rose-100" }, "Remove"))), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-5 xl:grid-cols-[280px_minmax(0,1fr)]" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-3 xl:sticky xl:top-5 xl:self-start" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),rgba(2,6,23,0.92))] p-3" }, /* @__PURE__ */ React.createElement("div", { className: "mb-3 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.08] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-200" }, comparison.display_type || "Comparison"), comparison.provider ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100" }, comparison.provider) : null, comparison.score ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/20 bg-amber-300/12 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, "Score ", comparison.score) : null), /* @__PURE__ */ React.createElement("div", { className: "overflow-hidden rounded-[20px] border border-white/10 bg-slate-950 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]" }, resolvePreviewUrl(comparison) ? /* @__PURE__ */ React.createElement("img", { src: resolvePreviewUrl(comparison), alt: comparison.model_name || comparison.provider || `Comparison ${index2 + 1}`, className: "h-72 w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-72 flex-col items-center justify-center gap-3 px-5 text-center text-sm text-slate-500" }, /* @__PURE__ */ React.createElement("span", { className: "flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-300" }, /* @__PURE__ */ React.createElement("i", { className: "fa-regular fa-image" })), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "font-semibold text-slate-200" }, "No comparison image yet"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs leading-5 text-slate-500" }, "Upload the generated result so editors can review differences at a glance."))))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 sm:grid-cols-2 lg:grid-cols-1" }, /* @__PURE__ */ React.createElement("label", { className: "cursor-pointer rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-center text-sm font-semibold text-sky-100 transition hover:bg-sky-300/18" }, /* @__PURE__ */ React.createElement( + "input", + { + type: "file", + accept: "image/jpeg,image/png,image/webp", + className: "hidden", + disabled: busyIndex === index2 || Boolean(bulkUploadState), + onChange: (event) => { + const file = event.target.files?.[0] || null; + if (file) { + handleUpload(index2, file); + } + event.target.value = ""; + } + } + ), busyIndex === index2 ? "Uploading..." : resolvePreviewUrl(comparison) ? "Replace image" : "Upload image"), comparison.image_path ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => clearMedia(index2), className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-slate-200 transition hover:bg-white/[0.08]" }, "Clear image") : null), /* @__PURE__ */ React.createElement("div", { className: "rounded-[20px] border border-white/10 bg-black/30 px-4 py-3 text-xs leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("div", { className: "font-semibold text-white" }, "Stored asset"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 break-all" }, comparison.image_path || "No uploaded comparison image yet."), comparison.model_name || comparison.provider ? /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex flex-wrap gap-2" }, comparison.model_name ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-200" }, comparison.model_name) : null, comparison.provider ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-200" }, comparison.provider) : null) : null)), /* @__PURE__ */ React.createElement("div", { className: "space-y-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2", role: "tablist", "aria-label": `Comparison ${index2 + 1} sections` }, PROMPT_COMPARISON_EDITOR_TABS.map((tab2) => { + const isActive = tab2.id === activeComparisonTab; + return /* @__PURE__ */ React.createElement( + "button", + { + key: tab2.id, + type: "button", + role: "tab", + "aria-selected": isActive, + onClick: () => setComparisonEditorTab(comparisonKey, tab2.id), + className: [ + "inline-flex items-center gap-2 rounded-2xl border px-4 py-2.5 text-sm font-semibold transition", + isActive ? "border-sky-300/25 bg-sky-300/12 text-sky-100 ring-1 ring-sky-300/20" : "border-white/10 bg-white/[0.03] text-white/80 hover:border-sky-300/30 hover:bg-sky-300/10 hover:text-white" + ].join(" ") + }, + /* @__PURE__ */ React.createElement("span", null, tab2.label) + ); + })), /* @__PURE__ */ React.createElement("p", { className: "text-xs leading-5 text-slate-400" }, activeComparisonTabMeta.description)), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[20px] border border-white/10 bg-white/[0.03] px-4 py-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Type"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm font-semibold text-white" }, comparison.display_type || "Default")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[20px] border border-white/10 bg-white/[0.03] px-4 py-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Provider"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm font-semibold text-white" }, comparison.provider || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[20px] border border-white/10 bg-white/[0.03] px-4 py-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Model"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm font-semibold text-white" }, comparison.model_name || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[20px] border border-white/10 bg-white/[0.03] px-4 py-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Visibility"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm font-semibold text-white" }, comparison.active ? "Visible" : "Hidden")))), activeComparisonTab === "summary" ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "mt-0 grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(NovaSelect, { label: "Type", value: comparison.display_type || "", onChange: (nextValue) => updateComparison(index2, "display_type", String(nextValue || "")), options: PROMPT_COMPARISON_TYPE_OPTIONS, className: "rounded-2xl bg-black/20" }), /* @__PURE__ */ React.createElement(TextField$1, { label: "Score", type: "number", min: "1", max: "10", value: comparison.score, onChange: (event) => updateComparison(index2, "score", event.target.value), placeholder: "1-10" })), /* @__PURE__ */ React.createElement("div", { className: "mt-0 grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(NovaSelect, { label: "Provider", value: comparison.provider || "", onChange: (nextValue) => updateComparison(index2, "provider", String(nextValue || "")), options: providerOptions, searchable: true, className: "rounded-2xl bg-black/20" }), /* @__PURE__ */ React.createElement(NovaSelect, { label: "Model", value: comparison.model_name || "", onChange: (nextValue) => updateComparison(index2, "model_name", String(nextValue || "")), options: modelOptions, searchable: true, className: "rounded-2xl bg-black/20" })), /* @__PURE__ */ React.createElement(TextAreaField, { label: "Notes", value: comparison.notes, onChange: (event) => updateComparison(index2, "notes", event.target.value), rows: 5, hint: "How does this provider interpret the prompt overall?" }), /* @__PURE__ */ React.createElement("label", { className: `flex cursor-pointer items-center justify-between gap-4 rounded-[24px] border px-5 py-4 transition ${comparison.active ? "border-[#f39a24]/35 bg-[#f39a24]/10" : "border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]"}` }, /* @__PURE__ */ React.createElement("input", { type: "checkbox", checked: Boolean(comparison.active), onChange: (event) => updateComparison(index2, "active", event.target.checked), className: "sr-only" }), /* @__PURE__ */ React.createElement("span", { className: "min-w-0" }, /* @__PURE__ */ React.createElement("span", { className: "block text-sm font-semibold tracking-[-0.02em] text-white" }, "Visible on frontend"), /* @__PURE__ */ React.createElement("span", { className: "mt-1 block text-sm leading-6 text-slate-300" }, "Turn this off to keep the comparison saved but hidden publicly.")), /* @__PURE__ */ React.createElement("span", { className: `inline-flex h-12 min-w-[92px] items-center justify-center rounded-full border px-4 text-sm font-semibold transition ${comparison.active ? "border-[#f39a24] bg-[#f39a24] text-white" : "border-white/10 bg-[#151a29] text-slate-300"}` }, comparison.active ? "Visible" : "Hidden")), /* @__PURE__ */ React.createElement(TextAreaField, { label: "Best for", value: comparison.best_for, onChange: (event) => updateComparison(index2, "best_for", event.target.value), rows: 4, hint: "What type of creator or output is this model the best fit for?" })) : null, activeComparisonTab === "setup" ? /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextAreaField, { label: "Generation details", value: comparison.settings, onChange: (event) => updateComparison(index2, "settings", event.target.value), rows: 7, hint: "Mention where it was generated, model mode, aspect ratio, or special settings." }), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-5 text-sm leading-7 text-slate-300" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "What to capture here"), /* @__PURE__ */ React.createElement("p", { className: "mt-3" }, "Record the setup details you would want when reproducing the result later: provider mode, prompt tweaks, seed or aspect ratio, and any notable generation constraints."), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.16em] text-slate-500" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5" }, "Mode"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5" }, "Aspect ratio"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5" }, "Prompt changes"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5" }, "Seed")))) : null, activeComparisonTab === "review" ? /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextAreaField, { label: "Strengths", value: comparison.strengths, onChange: (event) => updateComparison(index2, "strengths", event.target.value), rows: 7, hint: "What this model consistently does well with the prompt." }), /* @__PURE__ */ React.createElement(TextAreaField, { label: "Weaknesses", value: comparison.weaknesses, onChange: (event) => updateComparison(index2, "weaknesses", event.target.value), rows: 7, hint: "What tends to fail or need correction in post-processing." })) : null))); + }) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-dashed border-white/10 bg-black/20 px-6 py-8 text-sm text-slate-400" }, "No comparison blocks yet. Add one when the same prompt needs model-specific guidance."), /* @__PURE__ */ React.createElement("div", { className: "flex justify-center" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: addComparison, className: "rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/18" }, "+ Add AI Comparison")), /* @__PURE__ */ React.createElement( + "div", + { + className: [ + "rounded-[28px] border border-dashed px-6 py-8 transition", + isBulkDropActive ? "border-sky-300/40 bg-sky-300/10" : "border-white/10 bg-black/20 hover:border-sky-300/25 hover:bg-sky-300/[0.06]" + ].join(" "), + onDragOver: (event) => { + event.preventDefault(); + if (!bulkUploadState) { + setIsBulkDropActive(true); + } + }, + onDragLeave: (event) => { + event.preventDefault(); + if (event.currentTarget.contains(event.relatedTarget)) return; + setIsBulkDropActive(false); + }, + onDrop: (event) => { + event.preventDefault(); + if (bulkUploadState) return; + setIsBulkDropActive(false); + handleBulkUpload(event.dataTransfer?.files); + } + }, + /* @__PURE__ */ React.createElement( + "input", + { + ref: bulkFileInputRef, + type: "file", + multiple: true, + accept: "image/jpeg,image/png,image/webp", + className: "hidden", + disabled: Boolean(bulkUploadState), + onChange: (event) => { + handleBulkUpload(event.target.files); + event.target.value = ""; + } + } + ), + /* @__PURE__ */ React.createElement("div", { className: "mx-auto flex max-w-3xl flex-col items-center text-center" }, /* @__PURE__ */ React.createElement("span", { className: "flex h-14 w-14 items-center justify-center rounded-[20px] border border-white/10 bg-white/[0.04] text-sky-100" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-images text-lg" })), /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Bulk comparison uploads"), /* @__PURE__ */ React.createElement("h3", { className: "mt-2 text-xl font-semibold tracking-[-0.03em] text-white" }, "Drag and drop multiple images to create comparison blocks"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 max-w-2xl text-sm leading-7 text-slate-300" }, "Each uploaded image creates a new AI comparison block at the bottom, then runs through the same upload process used by the per-block image picker."), /* @__PURE__ */ React.createElement("div", { className: "mt-5 w-full max-w-md text-left" }, /* @__PURE__ */ React.createElement( + NovaSelect, + { + label: "Type for new blocks", + value: bulkComparisonType, + onChange: (nextValue) => setBulkComparisonType(String(nextValue || "")), + options: PROMPT_COMPARISON_TYPE_OPTIONS, + searchable: false, + className: "rounded-2xl bg-black/20" + } + ), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs leading-5 text-slate-500" }, "The selected type is applied automatically to every block created by this multi-image upload.")), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap items-center justify-center gap-3" }, /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + onClick: () => bulkFileInputRef.current?.click(), + disabled: Boolean(bulkUploadState), + className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-300/18 disabled:cursor-not-allowed disabled:opacity-60" + }, + bulkUploadState ? `Uploading ${bulkUploadState.current} of ${bulkUploadState.total}...` : "Select multiple images" + ), /* @__PURE__ */ React.createElement("span", { className: "text-xs uppercase tracking-[0.16em] text-slate-500" }, "or drop JPG, PNG, and WebP files here"))) + ), /* @__PURE__ */ React.createElement("div", { className: "space-y-4 rounded-[28px] border border-white/10 bg-black/20 p-5" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Provider and model library"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-300" }, "Keep reusable provider and model names here so comparison entries stay consistent and easy to scan.")), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 xl:grid-cols-2" }, /* @__PURE__ */ React.createElement( CodeListEditor, { title: "Providers", @@ -20760,24 +23408,9 @@ function PromptComparisonEditor({ comparisons, setComparisons, editorContext }) onAdd: addCustomModel, onRemove: removeCustomModel } - )), comparisons.length ? comparisons.map((comparison, index2) => /* @__PURE__ */ React.createElement("section", { key: comparison.client_key || `comparison-${index2}`, className: "rounded-[28px] border border-white/10 bg-black/20 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-200/75" }, "AI model comparison"), /* @__PURE__ */ React.createElement("h3", { className: "mt-2 text-lg font-semibold tracking-[-0.03em] text-white" }, comparison.model_name || `Comparison ${String(index2 + 1).padStart(2, "0")}`), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Document how this model handles the same prompt so creators can choose the right tool faster.")), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => moveComparison(index2, -1), disabled: index2 === 0, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white disabled:opacity-40" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-up" })), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => moveComparison(index2, 1), disabled: index2 === comparisons.length - 1, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white disabled:opacity-40" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-down" })), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => removeComparison(index2), className: "rounded-full border border-rose-300/20 bg-rose-300/10 px-3 py-2 text-xs font-semibold text-rose-100" }, "Remove"))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-3" }, /* @__PURE__ */ React.createElement("div", { className: "overflow-hidden rounded-[22px] border border-white/10 bg-slate-950" }, resolvePreviewUrl(comparison) ? /* @__PURE__ */ React.createElement("img", { src: resolvePreviewUrl(comparison), alt: comparison.model_name || comparison.provider || `Comparison ${index2 + 1}`, className: "h-40 w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-40 items-center justify-center px-4 text-center text-sm text-slate-500" }, "Upload generated output from this provider")), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2" }, /* @__PURE__ */ React.createElement("label", { className: "cursor-pointer rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-center text-sm font-semibold text-sky-100 transition hover:bg-sky-300/18" }, /* @__PURE__ */ React.createElement( - "input", - { - type: "file", - accept: "image/jpeg,image/png,image/webp", - className: "hidden", - disabled: busyIndex === index2, - onChange: (event) => { - const file = event.target.files?.[0] || null; - if (file) { - handleUpload(index2, file); - } - event.target.value = ""; - } - } - ), busyIndex === index2 ? "Uploading..." : resolvePreviewUrl(comparison) ? "Replace image" : "Upload image"), comparison.image_path ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => clearMedia(index2), className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-slate-200 transition hover:bg-white/[0.08]" }, "Clear image") : null), /* @__PURE__ */ React.createElement("div", { className: "rounded-[20px] border border-white/10 bg-black/30 px-4 py-3 text-xs leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("div", { className: "font-semibold text-white" }, "Stored asset"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 break-all" }, comparison.image_path || "No uploaded comparison image yet."))), /* @__PURE__ */ React.createElement("div", { className: "space-y-4" }, /* @__PURE__ */ React.createElement("div", { className: "mt-0 grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(NovaSelect, { label: "Provider", value: comparison.provider || "", onChange: (nextValue) => updateComparison(index2, "provider", String(nextValue || "")), options: providerOptions, searchable: true, className: "rounded-2xl bg-black/20" }), /* @__PURE__ */ React.createElement(NovaSelect, { label: "Model", value: comparison.model_name || "", onChange: (nextValue) => updateComparison(index2, "model_name", String(nextValue || "")), options: modelOptions, searchable: true, className: "rounded-2xl bg-black/20" })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextAreaField, { label: "Generation details", value: comparison.settings, onChange: (event) => updateComparison(index2, "settings", event.target.value), rows: 4, hint: "Mention where it was generated, model mode, aspect ratio, or special settings." }), /* @__PURE__ */ React.createElement(TextAreaField, { label: "Notes", value: comparison.notes, onChange: (event) => updateComparison(index2, "notes", event.target.value), rows: 4, hint: "How does this provider interpret the prompt overall?" })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-[minmax(0,1fr)_140px_180px]" }, /* @__PURE__ */ React.createElement(TextAreaField, { label: "Best for", value: comparison.best_for, onChange: (event) => updateComparison(index2, "best_for", event.target.value), rows: 4, hint: "What type of creator or output is this model the best fit for?" }), /* @__PURE__ */ React.createElement(TextField$1, { label: "Score", type: "number", min: "1", max: "10", value: comparison.score, onChange: (event) => updateComparison(index2, "score", event.target.value), placeholder: "1-10" }), /* @__PURE__ */ React.createElement(ToggleField$1, { label: "Visible on frontend", checked: Boolean(comparison.active), onChange: (event) => updateComparison(index2, "active", event.target.checked), help: "Turn this off to keep the comparison saved but hidden publicly." })))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextAreaField, { label: "Strengths", value: comparison.strengths, onChange: (event) => updateComparison(index2, "strengths", event.target.value), rows: 4, hint: "What this model consistently does well with the prompt." }), /* @__PURE__ */ React.createElement(TextAreaField, { label: "Weaknesses", value: comparison.weaknesses, onChange: (event) => updateComparison(index2, "weaknesses", event.target.value), rows: 4, hint: "What tends to fail or need correction in post-processing." })))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-dashed border-white/10 bg-black/20 px-6 py-8 text-sm text-slate-400" }, "No comparison blocks yet. Add one when the same prompt needs model-specific guidance.")); + )))); } -function Field$4({ field, form }) { +function Field$5({ field, form }) { const value = form.data[field.name]; if (field.type === "checkbox") { return /* @__PURE__ */ React.createElement("label", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("input", { type: "checkbox", checked: Boolean(value), onChange: (event) => form.setData(field.name, event.target.checked) }), field.label); @@ -20956,6 +23589,12 @@ function PromptPreviewDropzone({ form, previewUrl }) { function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext }) { const form = G$1({ ...record, new_category_name: "", preview_image_file: null, tool_notes: normalizePromptComparisons(record.tool_notes, { preserveEmpty: true }) }); const categoryField = reactExports.useMemo(() => getField(fields, "category_id"), [fields]); + const categoryOptions = reactExports.useMemo(() => { + const options = Array.isArray(categoryField?.options) ? categoryField.options : []; + const emptyOptions2 = options.filter((option) => String(option?.value ?? "") === ""); + const filledOptions = options.filter((option) => String(option?.value ?? "") !== "").slice().sort((left, right) => String(left?.label ?? "").localeCompare(String(right?.label ?? ""), void 0, { numeric: true, sensitivity: "base" })); + return [...emptyOptions2, ...filledOptions]; + }, [categoryField]); const difficultyField = reactExports.useMemo(() => getField(fields, "difficulty"), [fields]); const accessField = reactExports.useMemo(() => getField(fields, "access_level"), [fields]); const publishedAtField = reactExports.useMemo(() => getField(fields, "published_at"), [fields]); @@ -20963,11 +23602,16 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de const promptOfWeekField = reactExports.useMemo(() => getField(fields, "prompt_of_week"), [fields]); const activeField = reactExports.useMemo(() => getField(fields, "active"), [fields]); const seoDescriptionField = reactExports.useMemo(() => getField(fields, "seo_description"), [fields]); + const documentationField = reactExports.useMemo(() => getField(fields, "documentation"), [fields]); + const placeholdersField = reactExports.useMemo(() => getField(fields, "placeholders"), [fields]); + const helperPromptsField = reactExports.useMemo(() => getField(fields, "helper_prompts"), [fields]); + const promptVariantsField = reactExports.useMemo(() => getField(fields, "prompt_variants"), [fields]); const slugTouchedRef = reactExports.useRef(Boolean(String(record.slug || "").trim())); const [activeTab, setActiveTab] = reactExports.useState("overview"); const [jsonImportOpen, setJsonImportOpen] = reactExports.useState(false); const [jsonImportValue, setJsonImportValue] = reactExports.useState(""); const [jsonImportError, setJsonImportError] = reactExports.useState(""); + const [toast, setToast] = reactExports.useState({ id: 0, visible: false, message: "", variant: "success" }); const previewUrl = form.data.preview_image_url || ""; const tagCount = String(form.data.tags || "").split(/[,\n]/).map((item) => item.trim()).filter(Boolean).length; const promptWordCount = reactExports.useMemo(() => countPlainWords(form.data.prompt), [form.data.prompt]); @@ -20978,6 +23622,16 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de const visibleSections = reactExports.useMemo(() => new Set(activeTabMeta.sections), [activeTabMeta]); const sectionClassName = (sectionId, className = "") => `${visibleSections.has(sectionId) ? "" : "hidden"} ${className}`.trim(); const editorLinks = editorContext?.links || {}; + const [heroPreviewObjectUrl, setHeroPreviewObjectUrl] = reactExports.useState(""); + const heroPreviewImage = heroPreviewObjectUrl || previewUrl || form.data.preview_image || ""; + const showToast = (message, variant = "error") => { + setToast({ + id: Date.now() + Math.random(), + visible: true, + message, + variant + }); + }; reactExports.useEffect(() => { if (slugTouchedRef.current) return; form.setData("slug", slugifyPromptTitle(form.data.title)); @@ -20987,10 +23641,22 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de if (!nextTab) return; setActiveTab(nextTab); }, [form.errors]); + reactExports.useEffect(() => { + const previewFile = form.data.preview_image_file; + if (!(previewFile instanceof File)) { + setHeroPreviewObjectUrl(""); + return void 0; + } + const objectUrl = URL.createObjectURL(previewFile); + setHeroPreviewObjectUrl(objectUrl); + return () => { + URL.revokeObjectURL(objectUrl); + }; + }, [form.data.preview_image_file]); const applyJsonImport = () => { try { - const categoryOptions = Array.isArray(categoryField?.options) ? categoryField.options : []; - const parsed = parsePromptImport(jsonImportValue, categoryOptions); + const categoryOptions2 = Array.isArray(categoryField?.options) ? categoryField.options : []; + const parsed = parsePromptImport(jsonImportValue, categoryOptions2); Object.entries(parsed.next).forEach(([key, value]) => { form.setData(key, value); }); @@ -21005,29 +23671,70 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de }; const submit = (event) => { event.preventDefault(); + const advancedJsonFields = [ + { name: "documentation", label: documentationField?.label || "Documentation JSON" }, + { name: "placeholders", label: placeholdersField?.label || "Placeholders JSON" }, + { name: "helper_prompts", label: helperPromptsField?.label || "Helper Prompts JSON" }, + { name: "prompt_variants", label: promptVariantsField?.label || "Prompt Variants JSON" } + ]; + const parsedJsonFields = {}; + for (const field of advancedJsonFields) { + form.clearErrors(field.name); + const value = form.data[field.name]; + if (typeof value !== "string") { + parsedJsonFields[field.name] = value ?? null; + continue; + } + const trimmed = value.trim(); + if (!trimmed) { + parsedJsonFields[field.name] = null; + continue; + } + try { + parsedJsonFields[field.name] = JSON.parse(trimmed); + } catch { + const message = `${field.label} must be valid JSON.`; + form.setError(field.name, message); + showToast(message, "error"); + setActiveTab("advanced"); + return; + } + } const payload = normalizePayload(fields, { ...form.data, + ...parsedJsonFields, tool_notes: serializePromptComparisons(form.data.tool_notes) }); form.transform(() => payload); + const submitOptions = { + preserveScroll: true, + onError: (errors) => { + const nextTab = firstPromptErrorTab(errors); + if (nextTab) { + setActiveTab(nextTab); + } + showToast(firstErrorMessage$1(errors), "error"); + }, + onFinish: () => form.transform((data) => data) + }; if (method === "patch") { - form.patch(submitUrl); + form.patch(submitUrl, submitOptions); return; } - form.post(submitUrl); + form.post(submitUrl, submitOptions); }; - return /* @__PURE__ */ React.createElement(AdminLayout, { title, subtitle }, /* @__PURE__ */ React.createElement(Se$1, { title: `Admin · ${title}` }), /* @__PURE__ */ React.createElement("form", { onSubmit: submit, className: "space-y-6 pb-16" }, editorLinks.preview ? /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement(xe, { href: editorLinks.preview, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, "Preview public page")) : null, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.94))] shadow-[0_24px_70px_rgba(2,6,23,0.34)] backdrop-blur" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-4 border-b border-white/10 px-5 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400" }, /* @__PURE__ */ React.createElement(xe, { href: indexUrl, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white transition hover:bg-white/[0.08]" }, "Back to prompts"), /* @__PURE__ */ React.createElement("span", null, destroyUrl ? "Edit prompt" : "New prompt")), /* @__PURE__ */ React.createElement("h1", { className: "mt-3 text-3xl font-semibold tracking-[-0.05em] text-white" }, form.data.title || "Untitled academy prompt"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-3xl text-sm leading-7 text-slate-300" }, "Keep the prompt editor focused like a production worksheet: identity first, then the actual prompt body, then model comparisons and publishing details in separate tabs.")), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => setJsonImportOpen(true), className: "rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, "Import JSON"), /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: form.processing, className: "rounded-2xl border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-sm font-semibold text-sky-100" }, form.processing ? "Saving..." : "Save prompt")))), /* @__PURE__ */ React.createElement(PromptEditorTabs, { activeTab, onChange: setActiveTab, errorCounts: tabErrorCounts }), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.14)]" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Current workspace"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, activeTabMeta.label), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-2xl text-sm leading-6 text-slate-400" }, activeTabMeta.description)), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 sm:grid-cols-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Prompt words"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, promptWordCount.toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Negative words"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, negativePromptWordCount.toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Tags"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, tagCount)), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Comparisons"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, comparisonCount))))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0 space-y-6", role: "tabpanel", id: `prompt-editor-panel-${activeTab}`, "aria-labelledby": `prompt-editor-tab-${activeTab}` }, /* @__PURE__ */ React.createElement(SectionCard$4, { eyebrow: "Identity", title: "Core prompt details", description: "Set the catalog identity first so the prompt is easy to find, sort, and preview.", className: sectionClassName("prompt-identity") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, categoryField ? /* @__PURE__ */ React.createElement(NovaSelect, { label: categoryField.label, value: form.data.category_id ?? "", onChange: (nextValue) => { + return /* @__PURE__ */ React.createElement(AdminLayout, { title, subtitle }, /* @__PURE__ */ React.createElement(Se$1, { title: `Admin · ${title}` }), /* @__PURE__ */ React.createElement("form", { onSubmit: submit, className: "space-y-6 pb-16" }, editorLinks.preview ? /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement(xe, { href: editorLinks.preview, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, "Preview public page")) : null, /* @__PURE__ */ React.createElement("section", { className: "relative overflow-hidden rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.94))] shadow-[0_24px_70px_rgba(2,6,23,0.34)] backdrop-blur" }, heroPreviewImage ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "absolute inset-y-0 right-0 w-full bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_24%),linear-gradient(90deg,rgba(2,6,23,0.98)_0%,rgba(2,6,23,0.94)_34%,rgba(2,6,23,0.7)_100%)]" }), /* @__PURE__ */ React.createElement("img", { src: heroPreviewImage, alt: "", "aria-hidden": "true", className: "absolute inset-y-0 right-0 h-full w-full object-cover opacity-[0.08] blur-[5px]" })) : null, /* @__PURE__ */ React.createElement("div", { className: "relative grid gap-4 border-b border-white/10 px-5 py-3 lg:grid-cols-[140px_minmax(0,1fr)_auto] lg:items-stretch" }, /* @__PURE__ */ React.createElement("div", { className: "lg:min-h-[150px]" }, heroPreviewImage ? /* @__PURE__ */ React.createElement("div", { className: "h-full overflow-hidden rounded-[20px] border border-white/10 bg-black/25 shadow-[0_16px_34px_rgba(2,6,23,0.26)] backdrop-blur-sm" }, /* @__PURE__ */ React.createElement("div", { className: "relative h-full min-h-[150px] overflow-hidden" }, /* @__PURE__ */ React.createElement("img", { src: heroPreviewImage, alt: form.data.title || "Prompt preview", className: "h-full w-full object-cover" }), /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.04),rgba(2,6,23,0.48))]" }), /* @__PURE__ */ React.createElement("div", { className: "absolute inset-x-0 bottom-0 border-t border-white/10 bg-[linear-gradient(180deg,rgba(2,6,23,0.32),rgba(2,6,23,0.78))] px-3 py-2.5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/80" }, "Loaded preview"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-xs font-semibold text-white" }, "Current prompt image")))) : /* @__PURE__ */ React.createElement("div", { className: "flex h-full min-h-[150px] items-center justify-center rounded-[20px] border border-dashed border-white/10 bg-black/20 px-4 text-center text-xs leading-5 text-slate-400" }, "Upload a prompt preview image in the Media tab to surface it here.")), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1 self-center" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400" }, /* @__PURE__ */ React.createElement(xe, { href: indexUrl, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white transition hover:bg-white/[0.08]" }, "Back to prompts"), /* @__PURE__ */ React.createElement("span", null, destroyUrl ? "Edit prompt" : "New prompt")), /* @__PURE__ */ React.createElement("h1", { className: "mt-3 text-3xl font-semibold tracking-[-0.05em] text-white" }, form.data.title || "Untitled academy prompt"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-3xl text-sm leading-6 text-slate-300" }, "Keep the prompt editor focused like a production worksheet: identity first, then the actual prompt body, then model comparisons and publishing details in separate tabs.")), /* @__PURE__ */ React.createElement("div", { className: "self-start lg:justify-self-end" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-nowrap items-center gap-2 lg:justify-end" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => setJsonImportOpen(true), className: "inline-flex items-center gap-2 whitespace-nowrap rounded-2xl border border-white/10 bg-slate-800/90 px-4 py-2 text-sm font-semibold text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] transition hover:bg-slate-700/90" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-file-import text-xs" }), /* @__PURE__ */ React.createElement("span", null, "Import JSON")), /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: form.processing, className: "inline-flex items-center gap-2 whitespace-nowrap rounded-2xl border border-sky-300/25 bg-sky-300/18 px-4 py-2 text-sm font-semibold text-sky-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] transition hover:bg-sky-300/24" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-floppy-disk text-xs" }), /* @__PURE__ */ React.createElement("span", null, form.processing ? "Saving..." : "Save prompt")))))), /* @__PURE__ */ React.createElement(PromptEditorTabs, { activeTab, onChange: setActiveTab, errorCounts: tabErrorCounts }), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.14)]" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Current workspace"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, activeTabMeta.label), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-2xl text-sm leading-6 text-slate-400" }, activeTabMeta.description)), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 sm:grid-cols-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Prompt words"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, promptWordCount.toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Negative words"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, negativePromptWordCount.toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Tags"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, tagCount)), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Comparisons"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, comparisonCount))))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0 space-y-6", role: "tabpanel", id: `prompt-editor-panel-${activeTab}`, "aria-labelledby": `prompt-editor-tab-${activeTab}` }, /* @__PURE__ */ React.createElement(SectionCard$4, { eyebrow: "Identity", title: "Core prompt details", description: "Set the catalog identity first so the prompt is easy to find, sort, and preview.", className: sectionClassName("prompt-identity") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, categoryField ? /* @__PURE__ */ React.createElement(NovaSelect, { label: categoryField.label, value: form.data.category_id ?? "", onChange: (nextValue) => { form.setData("category_id", nextValue ?? ""); if (nextValue) { form.setData("new_category_name", ""); } - }, options: categoryField.options || [], searchable: false, className: "rounded-2xl bg-black/20", error: form.errors.category_id }) : null, /* @__PURE__ */ React.createElement(TextField$1, { label: "Or enter new category", value: form.data.new_category_name || "", onChange: (event) => form.setData("new_category_name", event.target.value), error: form.errors.new_category_name, placeholder: "New prompt category name" }), difficultyField ? /* @__PURE__ */ React.createElement(NovaSelect, { label: difficultyField.label, value: form.data.difficulty ?? "", onChange: (nextValue) => form.setData("difficulty", nextValue ?? ""), options: difficultyField.options || [], searchable: false, className: "rounded-2xl bg-black/20", error: form.errors.difficulty }) : null), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-slate-400" }, "Choose an existing category from the dropdown or type a new category name. When you save, a new prompt category will be created automatically and attached to this prompt."), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, accessField ? /* @__PURE__ */ React.createElement(NovaSelect, { label: accessField.label, value: form.data.access_level ?? "", onChange: (nextValue) => form.setData("access_level", nextValue ?? ""), options: accessField.options || [], searchable: false, className: "rounded-2xl bg-black/20", error: form.errors.access_level }) : null, /* @__PURE__ */ React.createElement(TextField$1, { label: "Aspect ratio", value: form.data.aspect_ratio || "", onChange: (event) => form.setData("aspect_ratio", event.target.value), error: form.errors.aspect_ratio, placeholder: "1:1, 16:9, 3:2" })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextField$1, { label: "Title", value: form.data.title || "", onChange: (event) => form.setData("title", event.target.value), error: form.errors.title, maxLength: 180 }), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("span", null, "Slug"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => { + }, options: categoryOptions, searchable: true, searchPlaceholder: "Filter categories...", className: "rounded-2xl bg-black/20", error: form.errors.category_id }) : null, /* @__PURE__ */ React.createElement(TextField$1, { label: "Or enter new category", value: form.data.new_category_name || "", onChange: (event) => form.setData("new_category_name", event.target.value), error: form.errors.new_category_name, placeholder: "New prompt category name" }), difficultyField ? /* @__PURE__ */ React.createElement(NovaSelect, { label: difficultyField.label, value: form.data.difficulty ?? "", onChange: (nextValue) => form.setData("difficulty", nextValue ?? ""), options: difficultyField.options || [], searchable: false, className: "rounded-2xl bg-black/20", error: form.errors.difficulty }) : null), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-slate-400" }, "Choose an existing category from the dropdown or type a new category name. When you save, a new prompt category will be created automatically and attached to this prompt."), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, accessField ? /* @__PURE__ */ React.createElement(NovaSelect, { label: accessField.label, value: form.data.access_level ?? "", onChange: (nextValue) => form.setData("access_level", nextValue ?? ""), options: accessField.options || [], searchable: false, className: "rounded-2xl bg-black/20", error: form.errors.access_level }) : null, /* @__PURE__ */ React.createElement(TextField$1, { label: "Aspect ratio", value: form.data.aspect_ratio || "", onChange: (event) => form.setData("aspect_ratio", event.target.value), error: form.errors.aspect_ratio, placeholder: "1:1, 16:9, 3:2" })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextField$1, { label: "Title", value: form.data.title || "", onChange: (event) => form.setData("title", event.target.value), error: form.errors.title, maxLength: 180 }), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("span", null, "Slug"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => { slugTouchedRef.current = false; form.setData("slug", slugifyPromptTitle(form.data.title)); }, className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold text-white" }, "Sync")), /* @__PURE__ */ React.createElement("input", { value: form.data.slug || "", onChange: (event) => { slugTouchedRef.current = String(event.target.value).trim() !== ""; form.setData("slug", event.target.value); - }, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none", maxLength: 180, placeholder: "prompt-template-slug" }), form.errors.slug ? /* @__PURE__ */ React.createElement("p", { className: "text-xs text-rose-300" }, form.errors.slug) : null)), /* @__PURE__ */ React.createElement(TextAreaField, { label: "Excerpt", value: form.data.excerpt || "", onChange: (event) => form.setData("excerpt", event.target.value), error: form.errors.excerpt, rows: 4, hint: "Short summary shown in the library and preview cards." }), /* @__PURE__ */ React.createElement(TextField$1, { label: "Tags", value: form.data.tags || "", onChange: (event) => form.setData("tags", event.target.value), error: form.errors.tags, placeholder: "wallpaper, cinematic, neon, portrait" })), /* @__PURE__ */ React.createElement(SectionCard$4, { eyebrow: "Prompt body", title: "Prompt instructions", description: "Write the instruction stack, guardrails, and workflow notes without cramming publishing settings into the same view.", className: sectionClassName("prompt-body") }, /* @__PURE__ */ React.createElement(TextAreaField, { label: "Prompt", value: form.data.prompt || "", onChange: (event) => form.setData("prompt", event.target.value), error: form.errors.prompt, rows: 12, hint: "This is the main model instruction used by creators." }), /* @__PURE__ */ React.createElement(TextAreaField, { label: "Negative prompt", value: form.data.negative_prompt || "", onChange: (event) => form.setData("negative_prompt", event.target.value), error: form.errors.negative_prompt, rows: 6, hint: "Optional exclusions, artifacts, or anti-patterns to avoid." }), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextAreaField, { label: "Usage notes", value: form.data.usage_notes || "", onChange: (event) => form.setData("usage_notes", event.target.value), error: form.errors.usage_notes, rows: 6, hint: "Explain how to apply the prompt in a practical workflow." }), /* @__PURE__ */ React.createElement(TextAreaField, { label: "Workflow notes", value: form.data.workflow_notes || "", onChange: (event) => form.setData("workflow_notes", event.target.value), error: form.errors.workflow_notes, rows: 6, hint: "Internal editorial notes, camera settings, or prompt variants." }))), /* @__PURE__ */ React.createElement(SectionCard$4, { eyebrow: "Structured blocks", title: "AI model comparisons", description: "Add reusable same-prompt comparison notes without burying provider-specific behavior inside the main prompt body.", className: sectionClassName("prompt-comparisons") }, /* @__PURE__ */ React.createElement(PromptComparisonEditor, { comparisons: Array.isArray(form.data.tool_notes) ? form.data.tool_notes : [], setComparisons: (nextValue) => form.setData("tool_notes", normalizePromptComparisons(nextValue, { preserveEmpty: true })), editorContext })), /* @__PURE__ */ React.createElement("div", { className: sectionClassName("prompt-media") }, /* @__PURE__ */ React.createElement(PromptPreviewDropzone, { form, previewUrl })), /* @__PURE__ */ React.createElement(SectionCard$4, { eyebrow: "Publishing", title: "Release controls", description: "Choose when the prompt becomes visible and how it behaves in the academy.", className: sectionClassName("prompt-publishing") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, publishedAtField ? /* @__PURE__ */ React.createElement(DateTimePicker, { label: publishedAtField.label, value: form.data.published_at || "", onChange: (nextValue) => form.setData("published_at", nextValue || ""), error: form.errors.published_at, clearable: true, className: "bg-black/20" }) : null, /* @__PURE__ */ React.createElement(TextField$1, { label: "SEO title", value: form.data.seo_title || "", onChange: (event) => form.setData("seo_title", event.target.value), error: form.errors.seo_title, maxLength: 180 })), seoDescriptionField ? /* @__PURE__ */ React.createElement(TextAreaField, { label: seoDescriptionField.label, value: form.data.seo_description || "", onChange: (event) => form.setData("seo_description", event.target.value), error: form.errors.seo_description, rows: 4 }) : null, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 md:grid-cols-3" }, featuredField ? /* @__PURE__ */ React.createElement(ToggleField$1, { label: featuredField.label, checked: Boolean(form.data.featured), onChange: (event) => form.setData("featured", event.target.checked), help: "Highlight this prompt in featured rails.", error: form.errors.featured }) : null, promptOfWeekField ? /* @__PURE__ */ React.createElement(ToggleField$1, { label: promptOfWeekField.label, checked: Boolean(form.data.prompt_of_week), onChange: (event) => form.setData("prompt_of_week", event.target.checked), help: "Promote this prompt as the current weekly pick.", error: form.errors.prompt_of_week }) : null, activeField ? /* @__PURE__ */ React.createElement(ToggleField$1, { label: activeField.label, checked: Boolean(form.data.active), onChange: (event) => form.setData("active", event.target.checked), help: "Keep draft prompts hidden until they are ready.", error: form.errors.active }) : null)), /* @__PURE__ */ React.createElement(SectionCard$4, { eyebrow: "Preview", title: "Public-facing snapshot", description: "Check the prompt card summary, tags, and current image before publishing.", className: sectionClassName("prompt-preview") }, /* @__PURE__ */ React.createElement("div", { className: "overflow-hidden rounded-[24px] border border-white/10 bg-black/30" }, previewUrl || form.data.preview_image ? /* @__PURE__ */ React.createElement("img", { src: previewUrl || form.data.preview_image, alt: "Prompt preview", className: "h-64 w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-64 items-center justify-center px-6 text-center text-sm text-slate-500" }, "No preview image selected yet.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Prompt summary"), /* @__PURE__ */ React.createElement("h3", { className: "mt-3 text-2xl font-semibold tracking-[-0.04em] text-white" }, form.data.title || "Untitled prompt"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-7 text-slate-400" }, form.data.excerpt || "Add a concise excerpt to give the prompt some context in the library."), /* @__PURE__ */ React.createElement("dl", { className: "mt-4 grid grid-cols-2 gap-3 text-xs text-slate-400" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2" }, /* @__PURE__ */ React.createElement("dt", { className: "uppercase tracking-[0.16em] text-slate-500" }, "Difficulty"), /* @__PURE__ */ React.createElement("dd", { className: "mt-1 text-sm text-white" }, form.data.difficulty || "—")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2" }, /* @__PURE__ */ React.createElement("dt", { className: "uppercase tracking-[0.16em] text-slate-500" }, "Access"), /* @__PURE__ */ React.createElement("dd", { className: "mt-1 text-sm text-white" }, form.data.access_level || "—")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2" }, /* @__PURE__ */ React.createElement("dt", { className: "uppercase tracking-[0.16em] text-slate-500" }, "Aspect"), /* @__PURE__ */ React.createElement("dd", { className: "mt-1 text-sm text-white" }, form.data.aspect_ratio || "—")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2" }, /* @__PURE__ */ React.createElement("dt", { className: "uppercase tracking-[0.16em] text-slate-500" }, "Comparisons"), /* @__PURE__ */ React.createElement("dd", { className: "mt-1 text-sm text-white" }, comparisonCount)))))), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 space-y-6 xl:sticky xl:top-6 xl:self-start" }, /* @__PURE__ */ React.createElement(SectionCard$4, { eyebrow: "At a glance", title: "Prompt status", description: "A compact summary while you work through the tabs." }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 sm:grid-cols-2 xl:grid-cols-1" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Prompt words"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-lg font-semibold text-white" }, promptWordCount.toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Tags"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-lg font-semibold text-white" }, tagCount)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Comparisons"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-lg font-semibold text-white" }, comparisonCount)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Visibility"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-lg font-semibold text-white" }, form.data.active ? "Active" : "Draft"))), /* @__PURE__ */ React.createElement("p", { className: "text-xs leading-6 text-slate-500" }, "Uploaded images are converted to WebP and stored on the Contabo S3-backed CDN before the record is saved.")))), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: form.processing, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, form.processing ? "Saving..." : "Save prompt"), /* @__PURE__ */ React.createElement(xe, { href: indexUrl, className: "rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white" }, "Back"), destroyUrl ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => { + }, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none", maxLength: 180, placeholder: "prompt-template-slug" }), form.errors.slug ? /* @__PURE__ */ React.createElement("p", { className: "text-xs text-rose-300" }, form.errors.slug) : null)), /* @__PURE__ */ React.createElement(TextAreaField, { label: "Excerpt", value: form.data.excerpt || "", onChange: (event) => form.setData("excerpt", event.target.value), error: form.errors.excerpt, rows: 4, hint: "Short summary shown in the library and preview cards." }), /* @__PURE__ */ React.createElement(TextField$1, { label: "Tags", value: form.data.tags || "", onChange: (event) => form.setData("tags", event.target.value), error: form.errors.tags, placeholder: "wallpaper, cinematic, neon, portrait" })), /* @__PURE__ */ React.createElement(SectionCard$4, { eyebrow: "Prompt body", title: "Prompt instructions", description: "Write the instruction stack, guardrails, and workflow notes without cramming publishing settings into the same view.", className: sectionClassName("prompt-body") }, /* @__PURE__ */ React.createElement(TextAreaField, { label: "Prompt", value: form.data.prompt || "", onChange: (event) => form.setData("prompt", event.target.value), error: form.errors.prompt, rows: 12, hint: "This is the main model instruction used by creators." }), /* @__PURE__ */ React.createElement(TextAreaField, { label: "Negative prompt", value: form.data.negative_prompt || "", onChange: (event) => form.setData("negative_prompt", event.target.value), error: form.errors.negative_prompt, rows: 6, hint: "Optional exclusions, artifacts, or anti-patterns to avoid." }), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextAreaField, { label: "Usage notes", value: form.data.usage_notes || "", onChange: (event) => form.setData("usage_notes", event.target.value), error: form.errors.usage_notes, rows: 6, hint: "Explain how to apply the prompt in a practical workflow." }), /* @__PURE__ */ React.createElement(TextAreaField, { label: "Workflow notes", value: form.data.workflow_notes || "", onChange: (event) => form.setData("workflow_notes", event.target.value), error: form.errors.workflow_notes, rows: 6, hint: "Internal editorial notes, camera settings, or prompt variants." }))), /* @__PURE__ */ React.createElement(SectionCard$4, { eyebrow: "Structured docs", title: "Advanced prompt metadata", description: "Use JSON editors for advanced prompt guidance, variables, supporting prompts, and reusable variants. Keep them valid JSON so the public prompt page can render them safely.", className: sectionClassName("prompt-advanced") }, /* @__PURE__ */ React.createElement(TextAreaField, { label: documentationField?.label || "Documentation JSON", value: form.data.documentation || "", onChange: (event) => form.setData("documentation", event.target.value), error: form.errors.documentation, rows: 12, hint: "Object with summary, best_for, how_to_use, required_inputs, workflow, tips, common_mistakes, data_accuracy_notes, and display_notes." }), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 xl:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextAreaField, { label: placeholdersField?.label || "Placeholders JSON", value: form.data.placeholders || "", onChange: (event) => form.setData("placeholders", event.target.value), error: form.errors.placeholders, rows: 12, hint: "Array of variable objects with key, label, description, required, example, default, and type." }), /* @__PURE__ */ React.createElement(TextAreaField, { label: helperPromptsField?.label || "Helper Prompts JSON", value: form.data.helper_prompts || "", onChange: (event) => form.setData("helper_prompts", event.target.value), error: form.errors.helper_prompts, rows: 12, hint: "Array of supporting prompts used for data collection, preparation, validation, or refinement." })), /* @__PURE__ */ React.createElement(TextAreaField, { label: promptVariantsField?.label || "Prompt Variants JSON", value: form.data.prompt_variants || "", onChange: (event) => form.setData("prompt_variants", event.target.value), error: form.errors.prompt_variants, rows: 12, hint: "Array of alternative prompt versions with prompt, negative_prompt, recommended flags, and risk notes." })), /* @__PURE__ */ React.createElement(SectionCard$4, { eyebrow: "Structured blocks", title: "AI model comparisons", description: "Add reusable same-prompt comparison notes without burying provider-specific behavior inside the main prompt body.", className: sectionClassName("prompt-comparisons") }, /* @__PURE__ */ React.createElement(PromptComparisonEditor, { comparisons: Array.isArray(form.data.tool_notes) ? form.data.tool_notes : [], setComparisons: (nextValue) => form.setData("tool_notes", normalizePromptComparisons(nextValue, { preserveEmpty: true })), editorContext })), /* @__PURE__ */ React.createElement("div", { className: sectionClassName("prompt-media") }, /* @__PURE__ */ React.createElement(PromptPreviewDropzone, { form, previewUrl })), /* @__PURE__ */ React.createElement(SectionCard$4, { eyebrow: "Publishing", title: "Release controls", description: "Choose when the prompt becomes visible and how it behaves in the academy.", className: sectionClassName("prompt-publishing") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, publishedAtField ? /* @__PURE__ */ React.createElement(DateTimePicker, { label: publishedAtField.label, value: form.data.published_at || "", onChange: (nextValue) => form.setData("published_at", nextValue || ""), error: form.errors.published_at, clearable: true, className: "bg-black/20" }) : null, /* @__PURE__ */ React.createElement(TextField$1, { label: "SEO title", value: form.data.seo_title || "", onChange: (event) => form.setData("seo_title", event.target.value), error: form.errors.seo_title, maxLength: 180 })), seoDescriptionField ? /* @__PURE__ */ React.createElement(TextAreaField, { label: seoDescriptionField.label, value: form.data.seo_description || "", onChange: (event) => form.setData("seo_description", event.target.value), error: form.errors.seo_description, rows: 4 }) : null, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 md:grid-cols-3" }, featuredField ? /* @__PURE__ */ React.createElement(ToggleField$1, { label: featuredField.label, checked: Boolean(form.data.featured), onChange: (event) => form.setData("featured", event.target.checked), help: "Highlight this prompt in featured rails.", error: form.errors.featured }) : null, promptOfWeekField ? /* @__PURE__ */ React.createElement(ToggleField$1, { label: promptOfWeekField.label, checked: Boolean(form.data.prompt_of_week), onChange: (event) => form.setData("prompt_of_week", event.target.checked), help: "Promote this prompt as the current weekly pick.", error: form.errors.prompt_of_week }) : null, activeField ? /* @__PURE__ */ React.createElement(ToggleField$1, { label: activeField.label, checked: Boolean(form.data.active), onChange: (event) => form.setData("active", event.target.checked), help: "Keep draft prompts hidden until they are ready.", error: form.errors.active }) : null)), /* @__PURE__ */ React.createElement(SectionCard$4, { eyebrow: "Preview", title: "Public-facing snapshot", description: "Check the prompt card summary, tags, and current image before publishing.", className: sectionClassName("prompt-preview") }, /* @__PURE__ */ React.createElement("div", { className: "overflow-hidden rounded-[24px] border border-white/10 bg-black/30" }, previewUrl || form.data.preview_image ? /* @__PURE__ */ React.createElement("img", { src: previewUrl || form.data.preview_image, alt: "Prompt preview", className: "h-64 w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-64 items-center justify-center px-6 text-center text-sm text-slate-500" }, "No preview image selected yet.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, "Prompt summary"), /* @__PURE__ */ React.createElement("h3", { className: "mt-3 text-2xl font-semibold tracking-[-0.04em] text-white" }, form.data.title || "Untitled prompt"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-7 text-slate-400" }, form.data.excerpt || "Add a concise excerpt to give the prompt some context in the library."), /* @__PURE__ */ React.createElement("dl", { className: "mt-4 grid grid-cols-2 gap-3 text-xs text-slate-400" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2" }, /* @__PURE__ */ React.createElement("dt", { className: "uppercase tracking-[0.16em] text-slate-500" }, "Difficulty"), /* @__PURE__ */ React.createElement("dd", { className: "mt-1 text-sm text-white" }, form.data.difficulty || "—")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2" }, /* @__PURE__ */ React.createElement("dt", { className: "uppercase tracking-[0.16em] text-slate-500" }, "Access"), /* @__PURE__ */ React.createElement("dd", { className: "mt-1 text-sm text-white" }, form.data.access_level || "—")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2" }, /* @__PURE__ */ React.createElement("dt", { className: "uppercase tracking-[0.16em] text-slate-500" }, "Aspect"), /* @__PURE__ */ React.createElement("dd", { className: "mt-1 text-sm text-white" }, form.data.aspect_ratio || "—")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2" }, /* @__PURE__ */ React.createElement("dt", { className: "uppercase tracking-[0.16em] text-slate-500" }, "Comparisons"), /* @__PURE__ */ React.createElement("dd", { className: "mt-1 text-sm text-white" }, comparisonCount)))))), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 space-y-6 xl:sticky xl:top-6 xl:self-start" }, /* @__PURE__ */ React.createElement(SectionCard$4, { eyebrow: "At a glance", title: "Prompt status", description: "A compact summary while you work through the tabs." }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 sm:grid-cols-2 xl:grid-cols-1" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Prompt words"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-lg font-semibold text-white" }, promptWordCount.toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Tags"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-lg font-semibold text-white" }, tagCount)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Comparisons"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-lg font-semibold text-white" }, comparisonCount)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Visibility"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-lg font-semibold text-white" }, form.data.active ? "Active" : "Draft"))), /* @__PURE__ */ React.createElement("p", { className: "text-xs leading-6 text-slate-500" }, "Uploaded images are converted to WebP and stored on the Contabo S3-backed CDN before the record is saved.")))), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: form.processing, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, form.processing ? "Saving..." : "Save prompt"), /* @__PURE__ */ React.createElement(xe, { href: indexUrl, className: "rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white" }, "Back"), destroyUrl ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => { if (!window.confirm("Delete this record?")) return; At.delete(destroyUrl); }, className: "rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100" }, "Delete") : null)), /* @__PURE__ */ React.createElement( @@ -21040,25 +23747,59 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de onClose: () => setJsonImportOpen(false), onApply: applyJsonImport } + ), /* @__PURE__ */ React.createElement( + ShareToast, + { + key: toast.id, + message: toast.message, + visible: toast.visible, + variant: toast.variant, + duration: toast.variant === "error" ? 3200 : 2200, + onHide: () => setToast((current) => ({ ...current, visible: false })) + } )); } function GenericEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext }) { const form = G$1(record); const editorLinks = editorContext?.links || {}; + const [toast, setToast] = reactExports.useState({ id: 0, visible: false, message: "", variant: "success" }); + const showToast = (message, variant = "error") => { + setToast({ + id: Date.now() + Math.random(), + visible: true, + message, + variant + }); + }; const submit = (event) => { event.preventDefault(); const payload = normalizePayload(fields, form.data); form.transform(() => payload); + const submitOptions = { + preserveScroll: true, + onError: (errors) => showToast(firstErrorMessage$1(errors), "error"), + onFinish: () => form.transform((data) => data) + }; if (method === "patch") { - form.patch(submitUrl); + form.patch(submitUrl, submitOptions); return; } - form.post(submitUrl); + form.post(submitUrl, submitOptions); }; - return /* @__PURE__ */ React.createElement(AdminLayout, { title, subtitle }, /* @__PURE__ */ React.createElement(Se$1, { title: `Admin · ${title}` }), editorLinks.builder || editorLinks.preview ? /* @__PURE__ */ React.createElement("div", { className: "mb-5 flex flex-wrap gap-3" }, editorLinks.builder ? /* @__PURE__ */ React.createElement(xe, { href: editorLinks.builder, className: "rounded-full border border-amber-300/20 bg-amber-300/10 px-5 py-3 text-sm font-semibold text-amber-100" }, "Open builder") : null, editorLinks.preview ? /* @__PURE__ */ React.createElement(xe, { href: editorLinks.preview, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, "Preview public page") : null) : null, /* @__PURE__ */ React.createElement("form", { onSubmit: submit, className: "space-y-5 rounded-[30px] border border-white/[0.08] bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5" }, fields.map((field) => /* @__PURE__ */ React.createElement(Field$4, { key: field.name, field, form }))), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: form.processing, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, form.processing ? "Saving..." : "Save"), /* @__PURE__ */ React.createElement(xe, { href: indexUrl, className: "rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white" }, "Back"), destroyUrl ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => { + return /* @__PURE__ */ React.createElement(AdminLayout, { title, subtitle }, /* @__PURE__ */ React.createElement(Se$1, { title: `Admin · ${title}` }), editorLinks.builder || editorLinks.preview ? /* @__PURE__ */ React.createElement("div", { className: "mb-5 flex flex-wrap gap-3" }, editorLinks.builder ? /* @__PURE__ */ React.createElement(xe, { href: editorLinks.builder, className: "rounded-full border border-amber-300/20 bg-amber-300/10 px-5 py-3 text-sm font-semibold text-amber-100" }, "Open builder") : null, editorLinks.preview ? /* @__PURE__ */ React.createElement(xe, { href: editorLinks.preview, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, "Preview public page") : null) : null, /* @__PURE__ */ React.createElement("form", { onSubmit: submit, className: "space-y-5 rounded-[30px] border border-white/[0.08] bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5" }, fields.map((field) => /* @__PURE__ */ React.createElement(Field$5, { key: field.name, field, form }))), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: form.processing, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, form.processing ? "Saving..." : "Save"), /* @__PURE__ */ React.createElement(xe, { href: indexUrl, className: "rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white" }, "Back"), destroyUrl ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => { if (!window.confirm("Delete this record?")) return; At.delete(destroyUrl); - }, className: "rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100" }, "Delete") : null))); + }, className: "rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100" }, "Delete") : null)), /* @__PURE__ */ React.createElement( + ShareToast, + { + key: toast.id, + message: toast.message, + visible: toast.visible, + variant: toast.variant, + duration: toast.variant === "error" ? 3200 : 2200, + onHide: () => setToast((current) => ({ ...current, visible: false })) + } + )); } function AcademyCrudForm({ resource, title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext }) { if (resource === "courses") { @@ -21124,16 +23865,21 @@ function AcademyCrudForm({ resource, title, subtitle, fields, record, submitUrl, } ); } -const __vite_glob_0_9 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_19 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AcademyCrudForm }, Symbol.toStringTag, { value: "Module" })); const PROMPT_VIEW_STORAGE_KEY = "skinbase.admin.academy.prompts.view"; +const COURSE_VIEW_STORAGE_KEY = "skinbase.admin.academy.courses.view"; const PROMPT_VIEW_OPTIONS = [ { value: "gallery", label: "Gallery", icon: "fa-images" }, { value: "grid", label: "Grid", icon: "fa-grid-2" }, { value: "table", label: "Table", icon: "fa-table-list" } ]; +const COURSE_VIEW_OPTIONS = [ + { value: "grid", label: "Grid", icon: "fa-grid-2" }, + { value: "table", label: "Table", icon: "fa-table-list" } +]; function formatDateLabel$1(value) { if (!value) return "Recently updated"; const date = new Date(value); @@ -21143,6 +23889,47 @@ function formatDateLabel$1(value) { function paginationLabel(label) { return String(label || "").replace(/«/g, "Previous").replace(/»/g, "Next").replace(/<[^>]+>/g, "").trim(); } +function courseStatusMeta(status2) { + const normalized = String(status2 || "draft"); + if (normalized === "published") { + return { label: "Published", className: "border-emerald-300/20 bg-emerald-300/10 text-emerald-100" }; + } + if (normalized === "review") { + return { label: "Review", className: "border-amber-300/20 bg-amber-300/10 text-amber-100" }; + } + if (normalized === "archived") { + return { label: "Archived", className: "border-white/10 bg-white/[0.04] text-slate-300" }; + } + return { label: "Draft", className: "border-slate-500/20 bg-slate-500/10 text-slate-300" }; +} +function courseAccessMeta(accessLevel) { + const normalized = String(accessLevel || "free"); + if (normalized === "premium") { + return { label: "Premium", className: "border-[#ffcfbf]/20 bg-[#ffcfbf]/10 text-[#fff0ea]" }; + } + if (normalized === "mixed") { + return { label: "Mixed", className: "border-sky-300/20 bg-sky-300/10 text-sky-100" }; + } + return { label: "Free", className: "border-white/10 bg-white/[0.05] text-slate-200" }; +} +function courseSummary(items = [], summary = null) { + if (summary && typeof summary === "object") { + return { + total: Number(summary.total || 0), + published: Number(summary.published || 0), + featured: Number(summary.featured || 0), + drafts: Number(summary.drafts || 0), + visibleOnPage: Array.isArray(items) ? items.length : 0 + }; + } + return items.reduce((accumulator, item) => ({ + total: accumulator.total + 1, + published: accumulator.published + (item.status === "published" ? 1 : 0), + featured: accumulator.featured + (item.is_featured ? 1 : 0), + drafts: accumulator.drafts + (item.status === "draft" ? 1 : 0), + visibleOnPage: accumulator.visibleOnPage + 1 + }), { total: 0, published: 0, featured: 0, drafts: 0, visibleOnPage: 0 }); +} function promptSummary(items = []) { return items.reduce((summary, item) => ({ total: summary.total + 1, @@ -21156,11 +23943,82 @@ function PromptFlag({ children, tone = "default" }) { const toneClass = tone === "warm" ? "border-[#ffcfbf]/20 bg-[#ffcfbf]/10 text-[#fff0ea]" : tone === "sky" ? "border-sky-300/20 bg-sky-300/10 text-sky-100" : tone === "emerald" ? "border-emerald-300/20 bg-emerald-300/10 text-emerald-100" : "border-white/10 bg-white/[0.05] text-slate-200"; return /* @__PURE__ */ React.createElement("span", { className: `rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${toneClass}` }, children); } +function CoursePill({ children, tone = "default" }) { + const toneClass = tone === "warm" ? "border-[#ffcfbf]/20 bg-[#ffcfbf]/10 text-[#fff0ea]" : tone === "sky" ? "border-sky-300/20 bg-sky-300/10 text-sky-100" : tone === "emerald" ? "border-emerald-300/20 bg-emerald-300/10 text-emerald-100" : "border-white/10 bg-white/[0.05] text-slate-200"; + return /* @__PURE__ */ React.createElement("span", { className: `rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${toneClass}` }, children); +} +function CourseCover({ item, compact = false }) { + if (item.cover_image_url) { + return /* @__PURE__ */ React.createElement("img", { src: item.cover_image_url, alt: item.title, className: `h-full w-full object-cover transition duration-500 ${compact ? "group-hover:scale-[1.04]" : "group-hover:scale-[1.03]"}` }); + } + return /* @__PURE__ */ React.createElement("div", { className: "flex h-full items-center justify-center bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_28%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.18),transparent_24%),linear-gradient(135deg,rgba(15,23,42,0.98),rgba(30,41,59,0.94))] p-6 text-center text-slate-300" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Course cover"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm font-semibold text-white" }, "No cover image attached yet"))); +} +function CourseCoverWall({ items = [] }) { + const images = items.map((item) => item?.cover_image_url).filter(Boolean).slice(0, 4); + if (!images.length) { + return /* @__PURE__ */ React.createElement("div", { className: "flex min-h-[320px] items-center justify-center rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_24%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.18),transparent_26%),linear-gradient(135deg,rgba(12,18,31,0.98),rgba(30,41,59,0.94))] px-8 text-center" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-500" }, "Course cover wall"), /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-lg font-semibold text-white" }, "Course artwork will appear here once covers are added."))); + } + return /* @__PURE__ */ React.createElement("div", { className: "space-y-3" }, /* @__PURE__ */ React.createElement("div", { className: "overflow-hidden rounded-[30px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.2)]" }, /* @__PURE__ */ React.createElement("div", { className: "aspect-[16/10] overflow-hidden" }, /* @__PURE__ */ React.createElement("img", { src: images[0], alt: "", "aria-hidden": "true", className: "h-full w-full object-cover" }))), images.length > 1 ? /* @__PURE__ */ React.createElement("div", { className: "grid grid-cols-2 gap-3" }, images.slice(1, 4).map((image2, index2) => /* @__PURE__ */ React.createElement( + "div", + { + key: `${image2}-${index2}`, + className: "aspect-square overflow-hidden rounded-[24px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.2)]" + }, + /* @__PURE__ */ React.createElement("img", { src: image2, alt: "", "aria-hidden": "true", className: "h-full w-full object-cover" }) + ))) : null); +} +function CourseStatCard({ label, value, tone = "default" }) { + const toneClass = tone === "sky" ? "border-sky-300/20 bg-sky-300/10 text-sky-100" : tone === "emerald" ? "border-emerald-300/20 bg-emerald-300/10 text-emerald-100" : tone === "warm" ? "border-[#ffcfbf]/20 bg-[#ffcfbf]/10 text-[#fff0ea]" : "border-white/10 bg-black/20 text-slate-300"; + return /* @__PURE__ */ React.createElement("div", { className: `rounded-[24px] border px-5 py-4 ${toneClass}` }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] opacity-70" }, label), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, value)); +} function PromptActions({ item }) { - return /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, item.preview_url ? /* @__PURE__ */ React.createElement(xe, { href: item.preview_url, className: "rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-4 py-2 text-sm font-semibold text-[#fff0ea]" }, "Preview") : null, /* @__PURE__ */ React.createElement(xe, { href: item.edit_url, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Edit"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => { + return /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, item.preview_url ? /* @__PURE__ */ React.createElement(xe, { href: item.preview_url, className: "inline-flex items-center gap-2 rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-4 py-2 text-sm font-semibold text-[#fff0ea]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-eye text-xs" }), "Preview") : null, /* @__PURE__ */ React.createElement(xe, { href: item.edit_url, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-pen-to-square text-xs" }), "Edit"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => { if (!window.confirm("Delete this prompt?")) return; At.delete(item.destroy_url, { preserveScroll: true }); - }, className: "rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100" }, "Delete")); + }, className: "inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-trash text-xs" }), "Delete")); +} +function CourseActions({ item }) { + return /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement(xe, { href: item.builder_url, className: "inline-flex items-center gap-2 rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-4 py-2 text-sm font-semibold text-[#fff0ea]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-sitemap text-xs" }), "Builder"), /* @__PURE__ */ React.createElement(xe, { href: item.edit_url, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-pen-to-square text-xs" }), "Edit"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => { + if (!window.confirm("Delete this course?")) return; + At.delete(item.destroy_url, { preserveScroll: true }); + }, className: "inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-trash text-xs" }), "Delete")); +} +function CourseGridCard({ item }) { + const status2 = courseStatusMeta(item.status); + const access = courseAccessMeta(item.access_level); + return /* @__PURE__ */ React.createElement("article", { className: "group overflow-hidden rounded-[30px] border border-white/[0.08] bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(15,23,42,0.18))] shadow-[0_18px_60px_rgba(2,6,23,0.18)]" }, /* @__PURE__ */ React.createElement("div", { className: "relative h-56 overflow-hidden border-b border-white/10" }, /* @__PURE__ */ React.createElement(CourseCover, { item, compact: true }), /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.02),rgba(2,6,23,0.34))]" })), /* @__PURE__ */ React.createElement("div", { className: "p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement(CoursePill, { tone: "warm" }, item.lessons_count || 0, " lessons"), /* @__PURE__ */ React.createElement(CoursePill, { tone: item.is_featured ? "sky" : "default" }, item.is_featured ? "Featured" : "Course"), /* @__PURE__ */ React.createElement("span", { className: `rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${status2.className}` }, status2.label)), /* @__PURE__ */ React.createElement("h2", { className: "mt-4 text-xl font-semibold tracking-[-0.04em] text-white" }, item.title), item.subtitle ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-300" }, item.subtitle) : null, /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, item.excerpt || "No excerpt added yet."), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex items-center justify-between gap-3 text-sm text-slate-400" }, /* @__PURE__ */ React.createElement("span", null, access.label), /* @__PURE__ */ React.createElement("span", null, formatDateLabel$1(item.updated_at))), /* @__PURE__ */ React.createElement("div", { className: "mt-5" }, /* @__PURE__ */ React.createElement(CourseActions, { item })))); +} +function CourseTable({ items }) { + return /* @__PURE__ */ React.createElement("div", { className: "overflow-hidden rounded-[30px] border border-white/[0.08] bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(2,6,23,0.92))] shadow-[0_24px_80px_rgba(2,6,23,0.22)]" }, /* @__PURE__ */ React.createElement("div", { className: "overflow-x-auto" }, /* @__PURE__ */ React.createElement("table", { className: "min-w-full divide-y divide-white/10 text-left" }, /* @__PURE__ */ React.createElement("thead", { className: "bg-white/[0.04] text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400" }, /* @__PURE__ */ React.createElement("tr", null, /* @__PURE__ */ React.createElement("th", { className: "px-5 py-4" }, "Cover"), /* @__PURE__ */ React.createElement("th", { className: "px-5 py-4" }, "Course"), /* @__PURE__ */ React.createElement("th", { className: "px-5 py-4" }, "Access"), /* @__PURE__ */ React.createElement("th", { className: "px-5 py-4" }, "Status"), /* @__PURE__ */ React.createElement("th", { className: "px-5 py-4" }, "Lessons"), /* @__PURE__ */ React.createElement("th", { className: "px-5 py-4" }, "Updated"), /* @__PURE__ */ React.createElement("th", { className: "px-5 py-4 text-right" }, "Actions"))), /* @__PURE__ */ React.createElement("tbody", { className: "divide-y divide-white/10 text-sm text-slate-200" }, items.map((item) => { + const status2 = courseStatusMeta(item.status); + const access = courseAccessMeta(item.access_level); + return /* @__PURE__ */ React.createElement("tr", { key: item.id, className: "align-top transition hover:bg-white/[0.03]" }, /* @__PURE__ */ React.createElement("td", { className: "px-5 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "h-20 w-28 overflow-hidden rounded-2xl border border-white/10 bg-black/30" }, /* @__PURE__ */ React.createElement(CourseCover, { item, compact: true }))), /* @__PURE__ */ React.createElement("td", { className: "px-5 py-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, item.title), item.subtitle ? /* @__PURE__ */ React.createElement("p", { className: "mt-1 max-w-md text-sm leading-6 text-slate-400" }, item.subtitle) : null, /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-xl text-sm leading-6 text-slate-400" }, item.excerpt || "No excerpt added yet."))), /* @__PURE__ */ React.createElement("td", { className: "px-5 py-4" }, /* @__PURE__ */ React.createElement("span", { className: `inline-flex rounded-full border px-3 py-1 text-xs font-semibold ${access.className}` }, access.label)), /* @__PURE__ */ React.createElement("td", { className: "px-5 py-4" }, /* @__PURE__ */ React.createElement("span", { className: `inline-flex rounded-full border px-3 py-1 text-xs font-semibold ${status2.className}` }, status2.label)), /* @__PURE__ */ React.createElement("td", { className: "px-5 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-1 text-white" }, /* @__PURE__ */ React.createElement("p", null, item.lessons_count || 0, " lessons"), /* @__PURE__ */ React.createElement("p", null, item.is_featured ? "Featured" : "Standard"))), /* @__PURE__ */ React.createElement("td", { className: "px-5 py-4" }, formatDateLabel$1(item.updated_at)), /* @__PURE__ */ React.createElement("td", { className: "px-5 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex justify-end gap-2" }, /* @__PURE__ */ React.createElement(xe, { href: item.builder_url, className: "rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-2 text-xs font-semibold text-[#fff0ea]" }, "Builder"), /* @__PURE__ */ React.createElement(xe, { href: item.edit_url, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white" }, "Edit")))); + }))))); +} +function CourseSearchBar({ value, onChange, onSubmit, onClear, viewMode, onViewModeChange }) { + return /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-4 shadow-[0_18px_50px_rgba(2,6,23,0.14)]" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between" }, /* @__PURE__ */ React.createElement("form", { onSubmit, className: "flex flex-1 flex-col gap-3 sm:flex-row sm:items-center" }, /* @__PURE__ */ React.createElement("div", { className: "relative flex-1 max-w-2xl" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-magnifying-glass absolute left-3.5 top-1/2 -translate-y-1/2 text-xs text-slate-500" }), /* @__PURE__ */ React.createElement( + "input", + { + name: "search", + value, + onChange: (event) => onChange(event.target.value), + placeholder: "Search title, slug, subtitle, excerpt, or description…", + className: "w-full rounded-2xl border border-white/10 bg-black/20 py-3 pl-9 pr-4 text-sm text-white placeholder:text-slate-600 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10" + } + )), /* @__PURE__ */ React.createElement("button", { type: "submit", className: "rounded-2xl bg-sky-300/12 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-300/16" }, "Search"), value ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: onClear, className: "rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white/80 transition hover:bg-white/[0.08]" }, "Clear") : null), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, COURSE_VIEW_OPTIONS.map((option) => { + const active = option.value === viewMode; + return /* @__PURE__ */ React.createElement( + "button", + { + key: option.value, + type: "button", + onClick: () => onViewModeChange(option.value), + className: `inline-flex items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-semibold transition ${active ? "border-sky-300/25 bg-sky-300/12 text-sky-100" : "border-white/10 bg-white/[0.04] text-slate-200 hover:border-white/20 hover:bg-white/[0.07]"}` + }, + /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${option.icon} text-xs` }), + /* @__PURE__ */ React.createElement("span", null, option.label) + ); + })))); } function PromptPreview({ item, compact = false }) { if (item.preview_image_url) { @@ -21183,16 +24041,59 @@ function PromptTable({ items }) { function PromptHeroCollage({ items = [] }) { const images = items.map((item) => item?.preview_image_url).filter(Boolean).slice(0, 4); if (!images.length) { - return /* @__PURE__ */ React.createElement("div", { className: "flex min-h-[420px] items-center justify-center rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_24%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.18),transparent_26%),linear-gradient(135deg,rgba(12,18,31,0.98),rgba(30,41,59,0.94))] px-8 text-center" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-500" }, "Prompt preview wall"), /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-lg font-semibold text-white" }, "Preview images will appear here as prompts get covers."))); + return /* @__PURE__ */ React.createElement("div", { className: "flex min-h-[320px] items-center justify-center rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_24%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.18),transparent_26%),linear-gradient(135deg,rgba(12,18,31,0.98),rgba(30,41,59,0.94))] px-8 text-center" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-500" }, "Prompt preview wall"), /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-lg font-semibold text-white" }, "Preview images will appear here as prompts get covers."))); } - return /* @__PURE__ */ React.createElement("div", { className: "grid min-h-[420px] grid-cols-2 gap-3" }, images.map((image2, index2) => /* @__PURE__ */ React.createElement( + return /* @__PURE__ */ React.createElement("div", { className: "space-y-3" }, /* @__PURE__ */ React.createElement("div", { className: "overflow-hidden rounded-[30px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.2)]" }, /* @__PURE__ */ React.createElement("div", { className: "aspect-[16/10] overflow-hidden" }, /* @__PURE__ */ React.createElement("img", { src: images[0], alt: "", "aria-hidden": "true", className: "h-full w-full object-cover" }))), images.length > 1 ? /* @__PURE__ */ React.createElement("div", { className: "grid grid-cols-2 gap-3" }, images.slice(1, 4).map((image2, index2) => /* @__PURE__ */ React.createElement( "div", { key: `${image2}-${index2}`, - className: `overflow-hidden rounded-[28px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.2)] ${index2 === 0 ? "col-span-2 aspect-[16/9]" : index2 === 3 ? "aspect-[4/5]" : "aspect-square"}` + className: "overflow-hidden rounded-[24px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.2)] aspect-square" }, /* @__PURE__ */ React.createElement("img", { src: image2, alt: "", "aria-hidden": "true", className: "h-full w-full object-cover" }) - ))); + ))) : null); +} +function CourseIndexContent({ title, subtitle, items, createUrl, filters = {}, summary = {} }) { + const { url } = X$1(); + const courses = items?.data || []; + const [viewMode, setViewMode] = reactExports.useState("grid"); + const [searchValue, setSearchValue] = reactExports.useState(filters.search || ""); + reactExports.useEffect(() => { + setSearchValue(filters.search || ""); + }, [filters.search]); + reactExports.useEffect(() => { + if (typeof window === "undefined") return; + const storedView = window.localStorage.getItem(COURSE_VIEW_STORAGE_KEY); + if (COURSE_VIEW_OPTIONS.some((option) => option.value === storedView)) { + setViewMode(storedView); + } + }, []); + reactExports.useEffect(() => { + if (typeof window === "undefined") return; + window.localStorage.setItem(COURSE_VIEW_STORAGE_KEY, viewMode); + }, [viewMode]); + const stats = reactExports.useMemo(() => courseSummary(courses, summary), [courses, summary]); + const currentPath = url.split("?")[0]; + const hasSearch = Boolean(searchValue.trim()); + const meta = items?.meta || {}; + const handleSearch = (event) => { + event.preventDefault(); + At.get(currentPath, { search: searchValue.trim() || void 0 }, { preserveScroll: true, preserveState: true, replace: true }); + }; + const handleClearSearch = () => { + setSearchValue(""); + At.get(currentPath, {}, { preserveScroll: true, preserveState: true, replace: true }); + }; + return /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[38px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_24%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.16),transparent_24%),linear-gradient(135deg,rgba(4,9,18,0.98),rgba(15,23,42,0.92))] shadow-[0_28px_90px_rgba(2,6,23,0.28)]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-8 p-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start xl:p-10" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[#fff0ea]" }, "Academy moderation"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300" }, "Course library")), /* @__PURE__ */ React.createElement("h2", { className: "mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl xl:text-6xl" }, title), /* @__PURE__ */ React.createElement("p", { className: "mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg" }, subtitle, " Search courses quickly, switch between grid and table views, and jump into editing with a cleaner visual overview of covers, status, and lesson counts."), /* @__PURE__ */ React.createElement("div", { className: "mt-7 grid gap-3 sm:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement(CourseStatCard, { label: "Total", value: stats.total, tone: "sky" }), /* @__PURE__ */ React.createElement(CourseStatCard, { label: "Published", value: stats.published, tone: "emerald" }), /* @__PURE__ */ React.createElement(CourseStatCard, { label: "Featured", value: stats.featured, tone: "warm" }), /* @__PURE__ */ React.createElement(CourseStatCard, { label: "Drafts", value: stats.drafts })), /* @__PURE__ */ React.createElement("div", { className: "mt-7 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement(xe, { href: createUrl, className: "inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-plus text-xs" }), "Create course"), /* @__PURE__ */ React.createElement(xe, { href: "/academy/courses", className: "inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-book-open text-xs" }), "Open public courses"), /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-layer-group text-xs" }), meta.total || courses.length, " courses in view"))), /* @__PURE__ */ React.createElement("div", { className: "xl:pt-2" }, /* @__PURE__ */ React.createElement(CourseCoverWall, { items: courses })))), /* @__PURE__ */ React.createElement( + CourseSearchBar, + { + value: searchValue, + onChange: setSearchValue, + onSubmit: handleSearch, + onClear: handleClearSearch, + viewMode, + onViewModeChange: setViewMode + } + ), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm text-slate-400" }, meta.total ? /* @__PURE__ */ React.createElement(React.Fragment, null, "Showing ", meta.from || 0, "-", meta.to || 0, " of ", meta.total, " courses", hasSearch ? /* @__PURE__ */ React.createElement("span", { className: "ml-2 text-sky-200" }, "filtered by “", searchValue.trim(), "”") : null) : "Manage Academy courses below. Changes clear Academy cache automatically."), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement(xe, { href: createUrl, className: "inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-plus text-xs" }), "Create course"))), courses.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] px-6 py-12 text-center text-slate-400" }, hasSearch ? /* @__PURE__ */ React.createElement("div", { className: "space-y-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-lg font-semibold text-white" }, "No courses matched your search."), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: handleClearSearch, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Clear search")) : /* @__PURE__ */ React.createElement("div", { className: "space-y-3" }, /* @__PURE__ */ React.createElement("p", { className: "text-lg font-semibold text-white" }, "No courses exist yet."), /* @__PURE__ */ React.createElement(xe, { href: createUrl, className: "inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-plus text-xs" }), "Create the first course"))) : viewMode === "table" ? /* @__PURE__ */ React.createElement(CourseTable, { items: courses }) : /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2 2xl:grid-cols-3" }, courses.map((item) => /* @__PURE__ */ React.createElement(CourseGridCard, { key: item.id, item }))), /* @__PURE__ */ React.createElement(PaginationLinks, { links: items?.links })); } function PaginationLinks({ links = [] }) { if (!Array.isArray(links) || links.length <= 3) return null; @@ -21202,6 +24103,23 @@ function PaginationLinks({ links = [] }) { return link2.url ? /* @__PURE__ */ React.createElement(xe, { key: `${label}-${index2}`, href: link2.url, className: `rounded-full border px-4 py-2 text-sm font-semibold transition ${className}`, preserveScroll: true }, label) : /* @__PURE__ */ React.createElement("span", { key: `${label}-${index2}`, className: "rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm font-semibold text-slate-500" }, label); })); } +function renderCrudCell(column, item) { + if (column === "active") { + const active = Boolean(item.active); + return /* @__PURE__ */ React.createElement("span", { className: `inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${active ? "border-emerald-300/20 bg-emerald-300/10 text-emerald-100" : "border-white/10 bg-white/[0.04] text-slate-300"}` }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${active ? "fa-circle-check" : "fa-circle-minus"} text-[11px]` }), /* @__PURE__ */ React.createElement("span", null, active ? "Active" : "Inactive")); + } + if (column === "course_names") { + const courseNames = Array.isArray(item.course_names) ? item.course_names.filter(Boolean) : []; + if (courseNames.length === 0) { + return /* @__PURE__ */ React.createElement("span", { className: "text-sm text-slate-400" }, "Not attached"); + } + return /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, courseNames.map((courseName) => /* @__PURE__ */ React.createElement("span", { key: courseName, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-200" }, courseName))); + } + if (column === "course_order") { + return /* @__PURE__ */ React.createElement("span", { className: "text-sm text-white" }, item.course_order ?? "Not set"); + } + return /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-white" }, String(item[column] ?? "")); +} function PromptIndexContent({ title, subtitle, items, createUrl }) { const promptItems = items?.data || []; const summary = promptSummary(promptItems); @@ -21217,7 +24135,7 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) { if (typeof window === "undefined") return; window.localStorage.setItem(PROMPT_VIEW_STORAGE_KEY, viewMode); }, [viewMode]); - return /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[38px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_24%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.16),transparent_24%),linear-gradient(135deg,rgba(4,9,18,0.98),rgba(15,23,42,0.92))] shadow-[0_28px_90px_rgba(2,6,23,0.28)]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-8 p-6 xl:grid-cols-[minmax(0,1.08fr)_420px] xl:items-end xl:p-10" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[#fff0ea]" }, "Academy moderation"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300" }, "Prompt library")), /* @__PURE__ */ React.createElement("h2", { className: "mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl xl:text-6xl" }, title), /* @__PURE__ */ React.createElement("p", { className: "mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg" }, subtitle, " Review prompts in a visual-first moderation surface, jump into edits quickly, and switch between gallery, grid, or table depending on the task in front of you."), /* @__PURE__ */ React.createElement("div", { className: "mt-7 grid gap-3 sm:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Visual-first"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, "Curate covers and prompt outputs before opening the form.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Workflow-ready"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, "Switch between gallery, compact cards, and scan-heavy tables.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Comparison-aware"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, "Spot prompts with provider notes and attached result references."))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 flex flex-wrap gap-3" }, PROMPT_VIEW_OPTIONS.map((option) => { + return /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[38px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_24%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.16),transparent_24%),linear-gradient(135deg,rgba(4,9,18,0.98),rgba(15,23,42,0.92))] shadow-[0_28px_90px_rgba(2,6,23,0.28)]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-8 p-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start xl:p-10" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[#fff0ea]" }, "Academy moderation"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300" }, "Prompt library")), /* @__PURE__ */ React.createElement("h2", { className: "mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl xl:text-6xl" }, title), /* @__PURE__ */ React.createElement("p", { className: "mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg" }, subtitle, " Review prompts in a visual-first moderation surface, jump into edits quickly, and switch between gallery, grid, or table depending on the task in front of you."), /* @__PURE__ */ React.createElement("div", { className: "mt-7 grid gap-3 sm:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Visual-first"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, "Curate covers and prompt outputs before opening the form.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Workflow-ready"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, "Switch between gallery, compact cards, and scan-heavy tables.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Comparison-aware"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm font-semibold text-white" }, "Spot prompts with provider notes and attached result references."))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 flex flex-wrap gap-3" }, PROMPT_VIEW_OPTIONS.map((option) => { const active = option.value === viewMode; return /* @__PURE__ */ React.createElement( "button", @@ -21230,16 +24148,19 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) { /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${option.icon}` }), /* @__PURE__ */ React.createElement("span", null, option.label, " view") ); - })), /* @__PURE__ */ React.createElement("div", { className: "mt-7 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement(xe, { href: createUrl, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, "Create prompt"), /* @__PURE__ */ React.createElement(xe, { href: "/academy/prompts", className: "rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85" }, "Open public library"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85" }, summary.total, " prompts in view")), /* @__PURE__ */ React.createElement("div", { className: "mt-7 grid gap-3 sm:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Active"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, summary.active)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Featured"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, summary.featured)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Prompt of week"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, summary.promptOfWeek)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Comparisons"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, summary.comparisons)))), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement(PromptHeroCollage, { items: promptItems })))), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm text-slate-400" }, "Manage Academy content below. Changes clear Academy cache automatically."), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement(xe, { href: "/academy/prompts", className: "rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85" }, "View public library"), /* @__PURE__ */ React.createElement(xe, { href: createUrl, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, "Create prompt"))), promptItems.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] px-6 py-12 text-center text-slate-400" }, "No prompt templates exist yet.") : viewMode === "table" ? /* @__PURE__ */ React.createElement(PromptTable, { items: promptItems }) : viewMode === "grid" ? /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2 2xl:grid-cols-3" }, promptItems.map((item) => /* @__PURE__ */ React.createElement(PromptGridCard, { key: item.id, item }))) : /* @__PURE__ */ React.createElement("div", { className: "space-y-5" }, promptItems.map((item) => /* @__PURE__ */ React.createElement(PromptGalleryCard, { key: item.id, item }))), /* @__PURE__ */ React.createElement(PaginationLinks, { links: items?.links })); + })), /* @__PURE__ */ React.createElement("div", { className: "mt-7 flex flex-nowrap gap-3 overflow-x-auto pb-1" }, /* @__PURE__ */ React.createElement(xe, { href: createUrl, className: "inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-plus text-xs" }), "Create prompt"), /* @__PURE__ */ React.createElement(xe, { href: "/academy/prompts", className: "inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-book-open text-xs" }), "Open public library"), /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-layer-group text-xs" }), summary.total, " prompts in view")), /* @__PURE__ */ React.createElement("div", { className: "mt-7 grid gap-3 sm:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Active"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, summary.active)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Featured"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, summary.featured)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Prompt of week"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, summary.promptOfWeek)), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 px-5 py-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Comparisons"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, summary.comparisons)))), /* @__PURE__ */ React.createElement("div", { className: "xl:pt-2" }, /* @__PURE__ */ React.createElement(PromptHeroCollage, { items: promptItems })))), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm text-slate-400" }, "Manage Academy content below. Changes clear Academy cache automatically."), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement(xe, { href: "/academy/prompts", className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-book-open text-xs" }), "View public library"), /* @__PURE__ */ React.createElement(xe, { href: createUrl, className: "inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-plus text-xs" }), "Create prompt"))), promptItems.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] px-6 py-12 text-center text-slate-400" }, "No prompt templates exist yet.") : viewMode === "table" ? /* @__PURE__ */ React.createElement(PromptTable, { items: promptItems }) : viewMode === "grid" ? /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2 2xl:grid-cols-3" }, promptItems.map((item) => /* @__PURE__ */ React.createElement(PromptGridCard, { key: item.id, item }))) : /* @__PURE__ */ React.createElement("div", { className: "space-y-5" }, promptItems.map((item) => /* @__PURE__ */ React.createElement(PromptGalleryCard, { key: item.id, item }))), /* @__PURE__ */ React.createElement(PaginationLinks, { links: items?.links })); } function AcademyCrudIndex({ title, subtitle, items, columns, createUrl }) { const flash = X$1().props.flash || {}; - return /* @__PURE__ */ React.createElement(AdminLayout, { title, subtitle }, /* @__PURE__ */ React.createElement(Se$1, { title: `Admin · ${title}` }), flash.success ? /* @__PURE__ */ React.createElement("div", { className: "mb-6 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100" }, flash.success) : null, X$1().props.resource === "prompts" ? /* @__PURE__ */ React.createElement(PromptIndexContent, { title, subtitle, items, createUrl }) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "mb-6 flex items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm text-slate-400" }, "Manage Academy content below. Changes clear Academy cache automatically."), /* @__PURE__ */ React.createElement(xe, { href: createUrl, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, "Create record")), (items?.data || []).length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] px-6 py-12 text-center text-slate-400" }, "No records exist yet.") : /* @__PURE__ */ React.createElement("div", { className: "space-y-4" }, items.data.map((item) => /* @__PURE__ */ React.createElement("div", { key: item.id, className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 sm:grid-cols-2 xl:grid-cols-5" }, columns.map((column) => /* @__PURE__ */ React.createElement("div", { key: column }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, column.replaceAll("_", " ")), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-white" }, String(item[column] ?? ""))))), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3 lg:justify-end" }, item.builder_url ? /* @__PURE__ */ React.createElement(xe, { href: item.builder_url, className: "rounded-full border border-amber-300/20 bg-amber-300/10 px-4 py-2 text-sm font-semibold text-amber-100" }, "Builder") : null, /* @__PURE__ */ React.createElement(xe, { href: item.edit_url, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Edit"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => { + const resource = X$1().props.resource; + const filters = X$1().props.filters || {}; + const summary = X$1().props.summary || {}; + return /* @__PURE__ */ React.createElement(AdminLayout, { title, subtitle }, /* @__PURE__ */ React.createElement(Se$1, { title: `Admin · ${title}` }), flash.success ? /* @__PURE__ */ React.createElement("div", { className: "mb-6 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100" }, flash.success) : null, resource === "courses" ? /* @__PURE__ */ React.createElement(CourseIndexContent, { title, subtitle, items, createUrl, filters, summary }) : resource === "prompts" ? /* @__PURE__ */ React.createElement(PromptIndexContent, { title, subtitle, items, createUrl }) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "mb-6 flex items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm text-slate-400" }, "Manage Academy content below. Changes clear Academy cache automatically."), /* @__PURE__ */ React.createElement(xe, { href: createUrl, className: "rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100" }, "Create record")), (items?.data || []).length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] px-6 py-12 text-center text-slate-400" }, "No records exist yet.") : /* @__PURE__ */ React.createElement("div", { className: "space-y-4" }, items.data.map((item) => /* @__PURE__ */ React.createElement("div", { key: item.id, className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6" }, columns.map((column) => /* @__PURE__ */ React.createElement("div", { key: column }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, column.replaceAll("_", " ")), /* @__PURE__ */ React.createElement("div", { className: "mt-1" }, renderCrudCell(column, item))))), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3 lg:justify-end" }, item.builder_url ? /* @__PURE__ */ React.createElement(xe, { href: item.builder_url, className: "rounded-full border border-amber-300/20 bg-amber-300/10 px-4 py-2 text-sm font-semibold text-amber-100" }, "Builder") : null, /* @__PURE__ */ React.createElement(xe, { href: item.edit_url, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Edit"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => { if (!window.confirm("Delete this record?")) return; At.delete(item.destroy_url, { preserveScroll: true }); }, className: "rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100" }, "Delete")))))))); } -const __vite_glob_0_10 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_20 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AcademyCrudIndex }, Symbol.toStringTag, { value: "Module" })); @@ -21247,9 +24168,9 @@ function StatCard$c({ label, value }) { return /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, label), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-3xl font-bold text-white" }, value.toLocaleString())); } function AcademyDashboard({ stats, links }) { - return /* @__PURE__ */ React.createElement(AdminLayout, { title: "Academy Dashboard", subtitle: "Overview of Academy content, challenge activity, and future billing placeholders." }, /* @__PURE__ */ React.createElement(Se$1, { title: "Admin · Academy Dashboard" }), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 sm:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement(StatCard$c, { label: "Courses", value: stats.courses }), /* @__PURE__ */ React.createElement(StatCard$c, { label: "Lessons", value: stats.lessons }), /* @__PURE__ */ React.createElement(StatCard$c, { label: "Prompts", value: stats.prompts }), /* @__PURE__ */ React.createElement(StatCard$c, { label: "Prompt Packs", value: stats.packs }), /* @__PURE__ */ React.createElement(StatCard$c, { label: "Challenges", value: stats.challenges }), /* @__PURE__ */ React.createElement(StatCard$c, { label: "Submissions", value: stats.submissions }), /* @__PURE__ */ React.createElement(StatCard$c, { label: "Badges", value: stats.badges }), /* @__PURE__ */ React.createElement(StatCard$c, { label: "Creator Subscribers", value: stats.creator_subscribers }), /* @__PURE__ */ React.createElement(StatCard$c, { label: "Pro Subscribers", value: stats.pro_subscribers })), /* @__PURE__ */ React.createElement("div", { className: "mt-8 rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Modules"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3" }, Object.entries(links).map(([key, href]) => /* @__PURE__ */ React.createElement(xe, { key, href, className: "rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-4 text-sm font-semibold text-white transition hover:border-white/15 hover:bg-white/[0.05]" }, key.replaceAll("_", " ")))))); + return /* @__PURE__ */ React.createElement(AdminLayout, { title: "Academy Dashboard", subtitle: "Overview of Academy content, challenge activity, and live Academy subscription health." }, /* @__PURE__ */ React.createElement(Se$1, { title: "Admin · Academy Dashboard" }), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 sm:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement(StatCard$c, { label: "Courses", value: stats.courses }), /* @__PURE__ */ React.createElement(StatCard$c, { label: "Lessons", value: stats.lessons }), /* @__PURE__ */ React.createElement(StatCard$c, { label: "Prompts", value: stats.prompts }), /* @__PURE__ */ React.createElement(StatCard$c, { label: "Prompt Packs", value: stats.packs }), /* @__PURE__ */ React.createElement(StatCard$c, { label: "Challenges", value: stats.challenges }), /* @__PURE__ */ React.createElement(StatCard$c, { label: "Submissions", value: stats.submissions }), /* @__PURE__ */ React.createElement(StatCard$c, { label: "Badges", value: stats.badges }), /* @__PURE__ */ React.createElement(StatCard$c, { label: "Active Subscribers", value: stats.active_subscribers || 0 }), /* @__PURE__ */ React.createElement(StatCard$c, { label: "Creator Subscribers", value: stats.creator_subscribers }), /* @__PURE__ */ React.createElement(StatCard$c, { label: "Pro Subscribers", value: stats.pro_subscribers }), /* @__PURE__ */ React.createElement(StatCard$c, { label: "Grace Period", value: stats.grace_period_subscribers || 0 })), /* @__PURE__ */ React.createElement("div", { className: "mt-8 rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Modules"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3" }, Object.entries(links).map(([key, href]) => /* @__PURE__ */ React.createElement(xe, { key, href, className: "rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-4 text-sm font-semibold text-white transition hover:border-white/15 hover:bg-white/[0.05]" }, key.replaceAll("_", " ")))))); } -const __vite_glob_0_11 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_21 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AcademyDashboard }, Symbol.toStringTag, { value: "Module" })); @@ -21257,22 +24178,22 @@ function AcademySubmissions({ submissions }) { const flash = X$1().props.flash || {}; return /* @__PURE__ */ React.createElement(AdminLayout, { title: "Academy Challenge Submissions", subtitle: "Approve or reject Academy challenge entries." }, /* @__PURE__ */ React.createElement(Se$1, { title: "Admin · Academy Challenge Submissions" }), flash.success ? /* @__PURE__ */ React.createElement("div", { className: "mb-6 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100" }, flash.success) : null, /* @__PURE__ */ React.createElement("div", { className: "space-y-4" }, (submissions?.data || []).map((submission) => /* @__PURE__ */ React.createElement("article", { key: submission.id, className: "rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-start" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-3" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/80" }, submission.moderation_status), /* @__PURE__ */ React.createElement("span", { className: "text-sm text-slate-400" }, submission.challenge?.title || "Challenge")), /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, submission.artwork?.title || "Artwork removed"), /* @__PURE__ */ React.createElement("p", { className: "text-sm text-slate-400" }, submission.user?.name || "Unknown user", " · ", submission.ai_tool_used || "No tool noted"), submission.prompt_used ? /* @__PURE__ */ React.createElement("pre", { className: "whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200" }, submission.prompt_used) : null, submission.workflow_notes ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-300" }, submission.workflow_notes) : null), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3 lg:justify-end" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => At.post(submission.approve_url, {}, { preserveScroll: true }), className: "rounded-full border border-emerald-300/20 bg-emerald-300/10 px-4 py-2 text-sm font-semibold text-emerald-100" }, "Approve"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => At.post(submission.reject_url, {}, { preserveScroll: true }), className: "rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100" }, "Reject"))))))); } -const __vite_glob_0_13 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_23 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AcademySubmissions }, Symbol.toStringTag, { value: "Module" })); -function getCsrfToken$f() { +function getCsrfToken$h() { if (typeof document === "undefined") return ""; return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } -async function requestJson$o(url, { method = "POST", body: body2 } = {}) { +async function requestJson$q(url, { method = "POST", body: body2 } = {}) { const response = await fetch(url, { method, credentials: "same-origin", headers: { Accept: "application/json", "Content-Type": "application/json", - "X-CSRF-TOKEN": getCsrfToken$f(), + "X-CSRF-TOKEN": getCsrfToken$h(), "X-Requested-With": "XMLHttpRequest" }, body: body2 ? JSON.stringify(body2) : void 0 @@ -21369,7 +24290,7 @@ function AiBiographyAdmin() { setBusyKey(actionKey); setError(""); try { - const payload = await requestJson$o(url); + const payload = await requestJson$q(url); setNotice(payload.message || "Action completed."); At.reload({ only: ["records", "stats", "filters"], @@ -21446,14 +24367,14 @@ function AiBiographyAdmin() { )), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-2 text-xs leading-relaxed text-slate-300" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-slate-100" }, "Approved:"), " ", formatDateTime$5(record.approved_at)), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-slate-100" }, "Created:"), " ", formatDateTime$5(record.created_at)), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-slate-100" }, "Updated:"), " ", formatDateTime$5(record.updated_at)), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-slate-100" }, "Source hash:"), " ", record.source_hash || "—"))))); })), records.prev_page_url || records.next_page_url ? /* @__PURE__ */ React.createElement("div", { className: "mt-8 flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, records.prev_page_url ? /* @__PURE__ */ React.createElement(xe, { href: records.prev_page_url, preserveScroll: true, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-left text-[10px]" }), "Previous") : null), /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.16em] text-slate-400" }, "Showing page ", records.current_page || 1, " of ", records.last_page || 1), /* @__PURE__ */ React.createElement("div", null, records.next_page_url ? /* @__PURE__ */ React.createElement(xe, { href: records.next_page_url, preserveScroll: true, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]" }, "Next", /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-right text-[10px]" })) : null)) : null); } -const __vite_glob_0_80 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_90 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AiBiographyAdmin }, Symbol.toStringTag, { value: "Module" })); function AdminAiBiography() { return /* @__PURE__ */ React.createElement(AdminLayout, null, /* @__PURE__ */ React.createElement(AiBiographyAdmin, null)); } -const __vite_glob_0_14 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_24 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AdminAiBiography }, Symbol.toStringTag, { value: "Module" })); @@ -21477,7 +24398,7 @@ function AdminArtworks({ artworks }) { } ) : /* @__PURE__ */ React.createElement("span", { key: i, className: "rounded-lg px-3 py-1.5 text-xs text-slate-700", dangerouslySetInnerHTML: { __html: link2.label } })))))); } -const __vite_glob_0_15 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_25 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AdminArtworks }, Symbol.toStringTag, { value: "Module" })); @@ -21575,7 +24496,7 @@ function AuthAudit({ logs, filters, eventOptions, statusOptions }) { } ) : /* @__PURE__ */ React.createElement("span", { key: `${link2.label}-${index2}`, className: "rounded-lg px-3 py-1.5 text-xs text-slate-700", dangerouslySetInnerHTML: { __html: link2.label } })))) : null)); } -const __vite_glob_0_16 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_26 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AuthAudit }, Symbol.toStringTag, { value: "Module" })); @@ -21709,7 +24630,7 @@ function DailyActivity({ selectedDate, summary, queues, sections }) { } ))))); } -const __vite_glob_0_17 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_27 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: DailyActivity }, Symbol.toStringTag, { value: "Module" })); @@ -21744,7 +24665,7 @@ function Dashboard({ stats }) { /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white group-hover:text-rose-300 transition" }, item.label), /* @__PURE__ */ React.createElement("p", { className: "mt-0.5 text-xs text-slate-500" }, item.desc)) ))))); } -const __vite_glob_0_18 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_28 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: Dashboard }, Symbol.toStringTag, { value: "Module" })); @@ -21832,18 +24753,18 @@ const Checkbox = reactExports.forwardRef(function Checkbox2({ label, hint, error (label || hint) && /* @__PURE__ */ React.createElement("span", { className: "flex flex-col gap-0.5" }, label && /* @__PURE__ */ React.createElement("span", { className: "text-sm text-white/90 leading-snug" }, label), hint && /* @__PURE__ */ React.createElement("span", { className: "text-xs text-slate-500" }, hint)) ), error && /* @__PURE__ */ React.createElement("p", { role: "alert", className: "text-xs text-red-400", style: { paddingLeft: `calc(${dim} + 0.625rem)` } }, error)); }); -function getCsrfToken$e() { +function getCsrfToken$g() { if (typeof document === "undefined") return ""; return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } -async function requestJson$n(url, { method = "POST", body: body2 } = {}) { +async function requestJson$p(url, { method = "POST", body: body2 } = {}) { const response = await fetch(url, { method, credentials: "same-origin", headers: { Accept: "application/json", "Content-Type": "application/json", - "X-CSRF-TOKEN": getCsrfToken$e(), + "X-CSRF-TOKEN": getCsrfToken$g(), "X-Requested-With": "XMLHttpRequest" }, body: body2 ? JSON.stringify(body2) : void 0 @@ -21886,7 +24807,7 @@ function Badge$2({ label, tone = "slate" }) { }; return /* @__PURE__ */ React.createElement("span", { className: `inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${toneClasses2[tone] || toneClasses2.slate}` }, label); } -function Field$3({ label, help, children }) { +function Field$4({ label, help, children }) { return /* @__PURE__ */ React.createElement("label", { className: "block space-y-2" }, /* @__PURE__ */ React.createElement("span", { className: "text-sm font-semibold text-white" }, label), children, help ? /* @__PURE__ */ React.createElement("span", { className: "block text-xs leading-relaxed text-slate-400" }, help) : null); } function StatCard$8({ label, value, tone = "sky" }) { @@ -21992,7 +24913,7 @@ function FeaturedArtworksAdmin() { setNotice(""); try { const url = `${endpoints.search}?q=${encodeURIComponent(searchQuery.trim())}`; - const payload = await requestJson$n(url, { method: "GET" }); + const payload = await requestJson$p(url, { method: "GET" }); setSearchResults(Array.isArray(payload.results) ? payload.results : []); if ((payload.results || []).length === 0) { setNotice("No artworks matched that search."); @@ -22035,7 +24956,7 @@ function FeaturedArtworksAdmin() { setBusy("submit"); setNotice(""); try { - const payload = await requestJson$n( + const payload = await requestJson$p( editingId ? endpoints.updatePattern.replace("__FEATURE__", String(editingId)) : endpoints.store, { method: editingId ? "PATCH" : "POST", @@ -22060,7 +24981,7 @@ function FeaturedArtworksAdmin() { setBusy(`toggle-${entry.id}`); setNotice(""); try { - const payload = await requestJson$n(endpoints.togglePattern.replace("__FEATURE__", String(entry.id)), { + const payload = await requestJson$p(endpoints.togglePattern.replace("__FEATURE__", String(entry.id)), { method: "PATCH" }); syncPayload(payload); @@ -22077,7 +24998,7 @@ function FeaturedArtworksAdmin() { setBusy(`delete-${entry.id}`); setNotice(""); try { - const payload = await requestJson$n(endpoints.destroyPattern.replace("__FEATURE__", String(entry.id)), { + const payload = await requestJson$p(endpoints.destroyPattern.replace("__FEATURE__", String(entry.id)), { method: "DELETE" }); syncPayload(payload); @@ -22094,7 +25015,7 @@ function FeaturedArtworksAdmin() { setBusy(`force-${entry.id}`); setNotice(""); try { - const payload = await requestJson$n(endpoints.forceHeroPattern.replace("__FEATURE__", String(entry.id)), { + const payload = await requestJson$p(endpoints.forceHeroPattern.replace("__FEATURE__", String(entry.id)), { method: "PATCH" }); syncPayload(payload); @@ -22133,7 +25054,7 @@ function FeaturedArtworksAdmin() { alt: winner.artwork?.title || "Winner preview", className: "h-full min-h-[180px] w-full object-cover" } - )), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 sm:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.18em] text-slate-400" }, "Artist"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, winner.artwork?.owner?.display_name || "Unknown"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-sm text-slate-400" }, winner.artwork?.owner?.type === "group" ? "Group publisher" : `@${winner.artwork?.owner?.username || ""}`)), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.18em] text-slate-400" }, "Medal Score (30d)"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, winner.medals?.score_30d || 0)), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.18em] text-slate-400" }, "Priority"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, winner.priority)), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.18em] text-slate-400" }, "Featured Since"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, formatDateTime$3(winner.featured_at))), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 p-4 sm:col-span-2" }, /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.18em] text-slate-400" }, "Published At"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, formatDateTime$3(winner.artwork?.published_at))))) : null), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-xs font-semibold uppercase tracking-[0.2em] text-slate-400" }, editingId ? "Edit Entry" : "Create Entry"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, editingId ? `Featured entry #${editingId}` : "Add an artwork to the featured pool")), editingId ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: resetEditor, className: "rounded-full border border-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-200 transition hover:border-white/20 hover:bg-white/5" }, "Cancel edit") : null), !editingId ? /* @__PURE__ */ React.createElement("form", { onSubmit: handleArtworkSearch, className: "mt-6 space-y-4 rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement(Field$3, { label: "Artwork selector", help: "Search by artwork ID, title, slug, artist, or group. Pick a result to lock it into the form." }, /* @__PURE__ */ React.createElement("div", { className: "flex gap-3" }, /* @__PURE__ */ React.createElement( + )), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 sm:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.18em] text-slate-400" }, "Artist"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, winner.artwork?.owner?.display_name || "Unknown"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-sm text-slate-400" }, winner.artwork?.owner?.type === "group" ? "Group publisher" : `@${winner.artwork?.owner?.username || ""}`)), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.18em] text-slate-400" }, "Medal Score (30d)"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, winner.medals?.score_30d || 0)), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.18em] text-slate-400" }, "Priority"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, winner.priority)), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.18em] text-slate-400" }, "Featured Since"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, formatDateTime$3(winner.featured_at))), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 p-4 sm:col-span-2" }, /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.18em] text-slate-400" }, "Published At"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, formatDateTime$3(winner.artwork?.published_at))))) : null), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-xs font-semibold uppercase tracking-[0.2em] text-slate-400" }, editingId ? "Edit Entry" : "Create Entry"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.04em] text-white" }, editingId ? `Featured entry #${editingId}` : "Add an artwork to the featured pool")), editingId ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: resetEditor, className: "rounded-full border border-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-200 transition hover:border-white/20 hover:bg-white/5" }, "Cancel edit") : null), !editingId ? /* @__PURE__ */ React.createElement("form", { onSubmit: handleArtworkSearch, className: "mt-6 space-y-4 rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement(Field$4, { label: "Artwork selector", help: "Search by artwork ID, title, slug, artist, or group. Pick a result to lock it into the form." }, /* @__PURE__ */ React.createElement("div", { className: "flex gap-3" }, /* @__PURE__ */ React.createElement( "input", { type: "text", @@ -22162,7 +25083,7 @@ function FeaturedArtworksAdmin() { label: reason, tone: reason === "Missing preview" ? "rose" : "slate" })) - ).map((badge) => /* @__PURE__ */ React.createElement(Badge$2, { key: `selected-${badge.label}`, label: badge.label, tone: badge.tone }))))) : null, duplicateSelection ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-100" }, "This artwork already has a featured entry. Edit the existing row instead of creating a duplicate.") : null, /* @__PURE__ */ React.createElement("form", { onSubmit: handleSubmit, className: "mt-6 grid gap-4 sm:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$3, { label: "Priority", help: "Higher priority always wins before medal score is considered." }, /* @__PURE__ */ React.createElement( + ).map((badge) => /* @__PURE__ */ React.createElement(Badge$2, { key: `selected-${badge.label}`, label: badge.label, tone: badge.tone }))))) : null, duplicateSelection ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-100" }, "This artwork already has a featured entry. Edit the existing row instead of creating a duplicate.") : null, /* @__PURE__ */ React.createElement("form", { onSubmit: handleSubmit, className: "mt-6 grid gap-4 sm:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$4, { label: "Priority", help: "Higher priority always wins before medal score is considered." }, /* @__PURE__ */ React.createElement( "input", { type: "number", @@ -22171,13 +25092,13 @@ function FeaturedArtworksAdmin() { onChange: (event) => setForm((current) => ({ ...current, priority: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40" } - )), /* @__PURE__ */ React.createElement(Field$3, { label: "Active", help: "Inactive rows stay visible in admin but cannot win the homepage hero." }, /* @__PURE__ */ React.createElement("label", { className: "flex h-[52px] items-center gap-3 rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-slate-100" }, /* @__PURE__ */ React.createElement( + )), /* @__PURE__ */ React.createElement(Field$4, { label: "Active", help: "Inactive rows stay visible in admin but cannot win the homepage hero." }, /* @__PURE__ */ React.createElement("label", { className: "flex h-[52px] items-center gap-3 rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-slate-100" }, /* @__PURE__ */ React.createElement( Checkbox, { checked: Boolean(form.is_active), onChange: (event) => setForm((current) => ({ ...current, is_active: event.target.checked })) } - ), /* @__PURE__ */ React.createElement("span", null, form.is_active ? "Active on save" : "Inactive on save"))), /* @__PURE__ */ React.createElement(Field$3, { label: "Featured Since" }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: form.featured_at, onChange: (nextValue) => setForm((current) => ({ ...current, featured_at: nextValue })), placeholder: "Featured since", clearable: true, className: "bg-[#08111d]" })), /* @__PURE__ */ React.createElement(Field$3, { label: "Expires" }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: form.expires_at, onChange: (nextValue) => setForm((current) => ({ ...current, expires_at: nextValue })), placeholder: "Expiry date", clearable: true, className: "bg-[#08111d]" })), /* @__PURE__ */ React.createElement("div", { className: "sm:col-span-2 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement( + ), /* @__PURE__ */ React.createElement("span", null, form.is_active ? "Active on save" : "Inactive on save"))), /* @__PURE__ */ React.createElement(Field$4, { label: "Featured Since" }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: form.featured_at, onChange: (nextValue) => setForm((current) => ({ ...current, featured_at: nextValue })), placeholder: "Featured since", clearable: true, className: "bg-[#08111d]" })), /* @__PURE__ */ React.createElement(Field$4, { label: "Expires" }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: form.expires_at, onChange: (nextValue) => setForm((current) => ({ ...current, expires_at: nextValue })), placeholder: "Expiry date", clearable: true, className: "bg-[#08111d]" })), /* @__PURE__ */ React.createElement("div", { className: "sm:col-span-2 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement( "button", { type: "submit", @@ -22196,14 +25117,14 @@ function FeaturedArtworksAdmin() { } ), /* @__PURE__ */ React.createElement(NovaSelect, { value: filter2, onChange: (val) => setFilter(val), searchable: false, options: [{ value: "all", label: "All rows" }, { value: "active", label: "Active" }, { value: "inactive", label: "Inactive" }, { value: "expired", label: "Expired" }, { value: "winner", label: "Winner" }, { value: "eligible", label: "Eligible" }, { value: "ineligible", label: "Not eligible" }] }), /* @__PURE__ */ React.createElement("div", { className: "grid grid-cols-[1fr_auto] gap-3" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: sortKey, onChange: (val) => setSortKey(val), searchable: false, options: [{ value: "priority", label: "Priority" }, { value: "featured_at", label: "Featured Since" }, { value: "expires_at", label: "Expires" }, { value: "score_30d", label: "Medal Score (30d)" }] }), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => setSortDirection((current) => current === "desc" ? "asc" : "desc"), className: "rounded-2xl border border-white/10 px-4 py-3 text-sm font-semibold text-slate-100 transition hover:border-white/20 hover:bg-white/5" }, sortDirection === "desc" ? "Desc" : "Asc")))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 overflow-hidden rounded-[24px] border border-white/10" }, /* @__PURE__ */ React.createElement("div", { className: "hidden grid-cols-[1.2fr_1fr_0.5fr_0.9fr_0.9fr_0.7fr_1.5fr_0.9fr] gap-4 border-b border-white/10 bg-black/20 px-5 py-4 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400 lg:grid" }, /* @__PURE__ */ React.createElement("div", null, "Artwork"), /* @__PURE__ */ React.createElement("div", null, "Artist / Owner"), /* @__PURE__ */ React.createElement("div", null, "Priority"), /* @__PURE__ */ React.createElement("div", null, "Featured Since"), /* @__PURE__ */ React.createElement("div", null, "Expires"), /* @__PURE__ */ React.createElement("div", null, "Score (30d)"), /* @__PURE__ */ React.createElement("div", null, "Status"), /* @__PURE__ */ React.createElement("div", null, "Actions")), /* @__PURE__ */ React.createElement("div", { className: "divide-y divide-white/10" }, filteredEntries.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "px-5 py-10 text-center text-sm text-slate-400" }, "No featured entries match the current filter.") : filteredEntries.map((entry) => /* @__PURE__ */ React.createElement("div", { key: entry.id, className: "grid gap-5 bg-white/[0.02] px-5 py-5 lg:grid-cols-[1.2fr_1fr_0.5fr_0.9fr_0.9fr_0.7fr_1.5fr_0.9fr] lg:items-center" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 sm:grid-cols-[92px_1fr]" }, /* @__PURE__ */ React.createElement("a", { href: entry.artwork?.canonical_url || "#", target: "_blank", rel: "noreferrer", className: "overflow-hidden rounded-2xl border border-white/10 bg-[#08111d]" }, /* @__PURE__ */ React.createElement("img", { src: entry.artwork?.thumbnail?.url, alt: entry.artwork?.title || "Artwork preview", className: "h-24 w-full object-cover" })), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "text-sm font-semibold text-white" }, entry.artwork?.title || "Missing artwork"), /* @__PURE__ */ React.createElement("span", { className: "text-xs text-slate-400" }, "#", entry.artwork?.id || entry.artwork_id)), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-xs leading-6 text-slate-400" }, "Visibility: ", entry.artwork?.visibility || "—", " • Published: ", entry.artwork?.published_at ? "Yes" : "No"), entry.is_winner && entry.winner_reason ? /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-xs leading-6 text-amber-100" }, entry.winner_reason) : null)), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold text-white" }, entry.artwork?.owner?.display_name || "Unknown"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs text-slate-400" }, entry.artwork?.owner?.type === "group" ? "Group publisher" : `@${entry.artwork?.owner?.username || ""}`)), /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold text-white" }, entry.priority), /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-200" }, formatDateTime$3(entry.featured_at)), /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-200" }, formatDateTime$3(entry.expires_at)), /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold text-white" }, entry.medals?.score_30d || 0), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, (entry.status_badges || []).map((badge, index2) => /* @__PURE__ */ React.createElement(Badge$2, { key: `${entry.id}-${badge.label}-${index2}`, label: badge.label, tone: badge.tone }))), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2 lg:justify-end" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => editEntry(entry), className: "rounded-full border border-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-100 transition hover:border-white/20 hover:bg-white/5" }, "Edit"), capabilities.forceHeroEnabled ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => handleForceHero(entry), disabled: busy === `force-${entry.id}`, className: `rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] transition disabled:cursor-not-allowed disabled:opacity-60 ${entry.is_force_hero ? "border-amber-300/25 text-amber-100 hover:border-amber-300/40 hover:bg-amber-400/10" : "border-amber-300/15 text-amber-50 hover:border-amber-300/30 hover:bg-amber-400/5"}` }, busy === `force-${entry.id}` ? "Saving…" : entry.is_force_hero ? "Disable Force Hero" : "Force Hero") : null, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => handleToggle(entry), disabled: busy === `toggle-${entry.id}`, className: "rounded-full border border-sky-300/20 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-400/10 disabled:cursor-not-allowed disabled:opacity-60" }, busy === `toggle-${entry.id}` ? "Saving…" : entry.is_active ? "Deactivate" : "Activate"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => handleDelete(entry), disabled: busy === `delete-${entry.id}`, className: "rounded-full border border-rose-300/20 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-rose-100 transition hover:border-rose-300/40 hover:bg-rose-400/10 disabled:cursor-not-allowed disabled:opacity-60" }, busy === `delete-${entry.id}` ? "Deleting…" : "Delete")))))))))); } -const __vite_glob_0_39 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_49 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: FeaturedArtworksAdmin }, Symbol.toStringTag, { value: "Module" })); function AdminFeaturedArtworks() { return /* @__PURE__ */ React.createElement(AdminLayout, null, /* @__PURE__ */ React.createElement(FeaturedArtworksAdmin, null)); } -const __vite_glob_0_19 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_29 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AdminFeaturedArtworks }, Symbol.toStringTag, { value: "Module" })); @@ -22463,60 +25384,6 @@ function HomepageAnnouncementEditor({ /* @__PURE__ */ React.createElement(EditorContent, { editor }) ), error ? /* @__PURE__ */ React.createElement("p", { role: "alert", className: "text-xs text-red-400" }, error) : null); } -function ShareToast({ message = "Link copied!", visible = false, onHide, duration = 2e3, variant = "success" }) { - const [show, setShow] = reactExports.useState(false); - const config = variant === "error" ? { - border: "border-rose-300/25", - background: "bg-rose-950/90", - text: "text-rose-50", - icon: "text-rose-300", - role: "alert", - live: "assertive", - iconPath: "M12 9v3.75m0 3.75h.007v.008H12v-.008ZM10.29 3.86 1.82 18a1.875 1.875 0 0 0 1.606 2.813h16.148A1.875 1.875 0 0 0 21.18 18L12.71 3.86a1.875 1.875 0 0 0-3.42 0Z" - } : { - border: "border-white/[0.10]", - background: "bg-nova-800/90", - text: "text-white", - icon: "text-emerald-400", - role: "status", - live: "polite", - iconPath: "M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" - }; - reactExports.useEffect(() => { - if (visible) { - const enterTimer = requestAnimationFrame(() => setShow(true)); - const hideTimer = setTimeout(() => { - setShow(false); - setTimeout(() => onHide?.(), 200); - }, duration); - return () => { - cancelAnimationFrame(enterTimer); - clearTimeout(hideTimer); - }; - } else { - setShow(false); - } - }, [visible, duration, onHide]); - if (!visible) return null; - return reactDomExports.createPortal( - /* @__PURE__ */ React.createElement( - "div", - { - role: config.role, - "aria-live": config.live, - className: [ - "fixed bottom-24 left-1/2 z-[10001] -translate-x-1/2 rounded-full border px-5 py-2.5 text-sm font-medium shadow-xl backdrop-blur-md transition-all duration-200", - config.border, - config.background, - config.text, - show ? "translate-y-0 opacity-100" : "translate-y-3 opacity-0" - ].join(" ") - }, - /* @__PURE__ */ React.createElement("span", { className: "flex items-center gap-2" }, /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 20 20", fill: "currentColor", className: `h-4 w-4 ${config.icon}` }, /* @__PURE__ */ React.createElement("path", { fillRule: "evenodd", d: config.iconPath, clipRule: "evenodd" })), message) - ), - document.body - ); -} const BACKGROUND_IMAGE_ACCEPT = "image/jpeg,image/jpg,image/png,image/webp"; const BACKGROUND_IMAGE_MAX_BYTES = 5 * 1024 * 1024; const FORM_TABS = [ @@ -22571,7 +25438,7 @@ function firstErrorMessage(errors) { const value = errors[firstKey]; return Array.isArray(value) ? value[0] : value; } -function getCsrfToken$d() { +function getCsrfToken$f() { if (typeof document === "undefined") return ""; return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } @@ -22854,7 +25721,7 @@ function HomepageAnnouncementForm({ announcement, previewAnnouncement, options, method: "POST", credentials: "same-origin", headers: { - "X-CSRF-TOKEN": getCsrfToken$d(), + "X-CSRF-TOKEN": getCsrfToken$f(), "X-Requested-With": "XMLHttpRequest", Accept: "application/json" }, @@ -22936,7 +25803,7 @@ function HomepageAnnouncementForm({ announcement, previewAnnouncement, options, } }, help: "Turn this on to clear the saved background image on the next save." })) : null, activeTab === "behavior" ? /* @__PURE__ */ React.createElement(Section$1, { title: "Behavior", description: "Dismiss controls let you force a fresh surface when the message materially changes." }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(TextField, { label: "Dismiss version", value: form.data.dismiss_version, onChange: (event) => form.setData("dismiss_version", event.target.value), error: form.errors.dismiss_version, inputMode: "numeric" }), /* @__PURE__ */ React.createElement(SelectField, { label: "Placement", value: form.data.placement, onChange: (nextValue) => form.setData("placement", nextValue), options: options.placements, error: form.errors.placement })), /* @__PURE__ */ React.createElement(ToggleField, { label: "Users can dismiss this card", checked: Boolean(form.data.is_dismissible), onChange: (event) => form.setData("is_dismissible", event.target.checked), help: "When disabled, the card remains visible and no restore pill is shown." })) : null), /* @__PURE__ */ React.createElement("aside", { className: "space-y-6 xl:sticky xl:top-[7.5rem] xl:self-start" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement(Section$1, { title: "Preview", description: "Refresh the preview to render the sanitized content and resolved CTA payload exactly as the homepage card sees it." }, /* @__PURE__ */ React.createElement("div", { className: "-mx-6 -mt-6 mb-5 border-b border-white/10 bg-slate-950/92 px-6 py-4 backdrop-blur-xl" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: runPreview, disabled: previewBusy, className: "rounded-full border border-sky-300/20 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:bg-sky-300/18 disabled:opacity-60" }, previewBusy ? "Refreshing preview…" : "Refresh preview"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => submit(), className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, "Save changes")), previewError ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm text-rose-300" }, previewError) : null), /* @__PURE__ */ React.createElement("div", { className: "overflow-hidden rounded-[30px] border border-white/10 bg-black/20 py-2" }, /* @__PURE__ */ React.createElement(HomepageAnnouncement, { announcement: previewWithLocalImage, mode: "preview" }))))))); } -const __vite_glob_0_20 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_30 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: HomepageAnnouncementForm }, Symbol.toStringTag, { value: "Module" })); @@ -22946,14 +25813,14 @@ function formatDateRange(startsAt, endsAt) { const end = endsAt ? formatter.format(new Date(endsAt)) : "Open ended"; return `${start} → ${end}`; } -function StatusBadge({ status: status2, active }) { +function StatusBadge$1({ status: status2, active }) { const tone = status2 === "published" ? "border-emerald-300/20 bg-emerald-300/10 text-emerald-100" : status2 === "archived" ? "border-amber-300/20 bg-amber-300/10 text-amber-100" : "border-slate-300/15 bg-slate-300/10 text-slate-200"; return /* @__PURE__ */ React.createElement("span", { className: `inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${tone}` }, /* @__PURE__ */ React.createElement("span", { className: `h-2 w-2 rounded-full ${active ? "bg-emerald-300" : "bg-slate-500"}` }), status2); } function HomepageAnnouncementsIndex({ announcements, createUrl }) { const { props } = X$1(); const flash = props.flash ?? {}; - return /* @__PURE__ */ React.createElement(AdminLayout, { title: "Homepage Announcements", subtitle: "Schedule launch cards, homepage notices, and editorial announcements below the featured artwork hero." }, /* @__PURE__ */ React.createElement(Se$1, { title: "Admin · Homepage Announcements" }), flash.success ? /* @__PURE__ */ React.createElement("div", { className: "mb-6 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100" }, flash.success) : null, flash.error ? /* @__PURE__ */ React.createElement("div", { className: "mb-6 rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100" }, flash.error) : null, /* @__PURE__ */ React.createElement("div", { className: "mb-6 flex items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", { className: "max-w-2xl text-sm leading-6 text-slate-400" }, "Only the highest-priority published announcement that is active and inside its visibility window appears on the homepage."), /* @__PURE__ */ React.createElement(xe, { href: createUrl, className: "rounded-full border border-sky-300/20 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-300/18" }, "Create announcement")), /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, (announcements?.data || []).length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] px-6 py-10 text-center text-slate-400" }, "No homepage announcements exist yet.") : announcements.data.map((announcement) => /* @__PURE__ */ React.createElement("article", { key: announcement.id, className: "overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 border-b border-white/8 px-6 py-6 lg:grid-cols-[minmax(0,1.2fr)_auto] lg:items-start" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-3" }, /* @__PURE__ */ React.createElement(StatusBadge, { status: announcement.status, active: announcement.is_active }), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70" }, announcement.type), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70" }, "Priority ", announcement.priority), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70" }, "Dismiss v", announcement.dismiss_version)), /* @__PURE__ */ React.createElement("h2", { className: "mt-4 text-2xl font-semibold tracking-[-0.04em] text-white" }, announcement.title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-400" }, formatDateRange(announcement.starts_at, announcement.ends_at)), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-xs uppercase tracking-[0.18em] text-slate-500" }, announcement.placement.replaceAll("_", " "))), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3 lg:justify-end" }, /* @__PURE__ */ React.createElement(xe, { href: announcement.edit_url, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, "Edit"), /* @__PURE__ */ React.createElement( + return /* @__PURE__ */ React.createElement(AdminLayout, { title: "Homepage Announcements", subtitle: "Schedule launch cards, homepage notices, and editorial announcements below the featured artwork hero." }, /* @__PURE__ */ React.createElement(Se$1, { title: "Admin · Homepage Announcements" }), flash.success ? /* @__PURE__ */ React.createElement("div", { className: "mb-6 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100" }, flash.success) : null, flash.error ? /* @__PURE__ */ React.createElement("div", { className: "mb-6 rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100" }, flash.error) : null, /* @__PURE__ */ React.createElement("div", { className: "mb-6 flex items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", { className: "max-w-2xl text-sm leading-6 text-slate-400" }, "Only the highest-priority published announcement that is active and inside its visibility window appears on the homepage."), /* @__PURE__ */ React.createElement(xe, { href: createUrl, className: "rounded-full border border-sky-300/20 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-300/18" }, "Create announcement")), /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, (announcements?.data || []).length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] px-6 py-10 text-center text-slate-400" }, "No homepage announcements exist yet.") : announcements.data.map((announcement) => /* @__PURE__ */ React.createElement("article", { key: announcement.id, className: "overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 border-b border-white/8 px-6 py-6 lg:grid-cols-[minmax(0,1.2fr)_auto] lg:items-start" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-3" }, /* @__PURE__ */ React.createElement(StatusBadge$1, { status: announcement.status, active: announcement.is_active }), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70" }, announcement.type), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70" }, "Priority ", announcement.priority), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70" }, "Dismiss v", announcement.dismiss_version)), /* @__PURE__ */ React.createElement("h2", { className: "mt-4 text-2xl font-semibold tracking-[-0.04em] text-white" }, announcement.title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-400" }, formatDateRange(announcement.starts_at, announcement.ends_at)), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-xs uppercase tracking-[0.18em] text-slate-500" }, announcement.placement.replaceAll("_", " "))), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3 lg:justify-end" }, /* @__PURE__ */ React.createElement(xe, { href: announcement.edit_url, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, "Edit"), /* @__PURE__ */ React.createElement( "button", { type: "button", @@ -22975,7 +25842,7 @@ function HomepageAnnouncementsIndex({ announcements, createUrl }) { } ) : /* @__PURE__ */ React.createElement("span", { key: `${link2.label}-${index2}`, className: "rounded-lg px-3 py-1.5 text-xs text-slate-600", dangerouslySetInnerHTML: { __html: link2.label } })))) : null); } -const __vite_glob_0_21 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_31 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: HomepageAnnouncementsIndex }, Symbol.toStringTag, { value: "Module" })); @@ -23015,7 +25882,7 @@ function AdminSettings({ settings = {} }) { } )))))), /* @__PURE__ */ React.createElement("p", { className: "text-xs text-slate-600" }, "Full settings management via config files and environment variables."))); } -const __vite_glob_0_22 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_32 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AdminSettings }, Symbol.toStringTag, { value: "Module" })); @@ -23039,7 +25906,7 @@ function AdminStories({ stories }) { } ) : /* @__PURE__ */ React.createElement("span", { key: i, className: "rounded-lg px-3 py-1.5 text-xs text-slate-700", dangerouslySetInnerHTML: { __html: link2.label } })))))); } -const __vite_glob_0_23 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_33 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AdminStories }, Symbol.toStringTag, { value: "Module" })); @@ -23105,7 +25972,7 @@ function AdminUploadQueue() { function UploadQueuePage() { return /* @__PURE__ */ React.createElement(AdminLayout, { title: "Upload Queue", subtitle: "Review and moderate pending artwork submissions" }, /* @__PURE__ */ React.createElement(Se$1, { title: "Admin · Upload Queue" }), /* @__PURE__ */ React.createElement(AdminUploadQueue, null)); } -const __vite_glob_0_24 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_34 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: UploadQueuePage }, Symbol.toStringTag, { value: "Module" })); @@ -23169,7 +26036,7 @@ function AdminUsernameQueue() { function UsernameQueuePage() { return /* @__PURE__ */ React.createElement(AdminLayout, { title: "Username Queue", subtitle: "Review and approve pending username change requests" }, /* @__PURE__ */ React.createElement(Se$1, { title: "Admin · Username Queue" }), /* @__PURE__ */ React.createElement(AdminUsernameQueue, null)); } -const __vite_glob_0_25 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_35 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: UsernameQueuePage }, Symbol.toStringTag, { value: "Module" })); @@ -23265,7 +26132,7 @@ function UsersIndex({ users, filters, roles }) { } ) : /* @__PURE__ */ React.createElement("span", { key: i, className: "rounded-lg px-3 py-1.5 text-xs text-slate-700", dangerouslySetInnerHTML: { __html: link2.label } })))))); } -const __vite_glob_0_26 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_36 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: UsersIndex }, Symbol.toStringTag, { value: "Module" })); @@ -23329,7 +26196,7 @@ function SimilarArtworksHeader({ artwork }) { artwork.content_type_name || "artworks" ))))); } -const __vite_glob_0_27 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_37 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: SimilarArtworksHeader }, Symbol.toStringTag, { value: "Module" })); @@ -38567,7 +41434,7 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present } )); } -const __vite_glob_0_28 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_38 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: ArtworkPage }, Symbol.toStringTag, { value: "Module" })); @@ -70917,7 +73784,7 @@ if (typeof document !== "undefined") { clientExports.createRoot(mountElement).render(/* @__PURE__ */ React.createElement(CategoriesPage, { ...props })); } } -const __vite_glob_0_29 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_39 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: CategoriesPage }, Symbol.toStringTag, { value: "Module" })); @@ -70939,7 +73806,7 @@ function CollectionAnalytics() { const seo = props.seo || {}; return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Se$1, null, /* @__PURE__ */ React.createElement("title", null, seo.title || `${collection.title || "Collection"} Analytics — Skinbase`), /* @__PURE__ */ React.createElement("meta", { name: "description", content: seo.description || "Collection analytics overview." }), seo.canonical ? /* @__PURE__ */ React.createElement("link", { rel: "canonical", href: seo.canonical }) : null, /* @__PURE__ */ React.createElement("meta", { name: "robots", content: seo.robots || "noindex,follow" })), /* @__PURE__ */ React.createElement("div", { className: "relative min-h-screen overflow-hidden pb-16" }, /* @__PURE__ */ React.createElement("div", { "aria-hidden": "true", className: "pointer-events-none absolute inset-x-0 top-0 -z-10 h-[32rem] opacity-95", style: { background: "radial-gradient(circle at 14% 14%, rgba(56,189,248,0.18), transparent 26%), radial-gradient(circle at 86% 18%, rgba(16,185,129,0.16), transparent 24%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)" } }), /* @__PURE__ */ React.createElement("div", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 -z-10 opacity-[0.05]", style: { backgroundImage: "url(/gfx/noise.png)", backgroundSize: "180px" } }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-7xl px-4 pt-8 md:px-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-3 text-sm text-slate-300" }, props.dashboardUrl ? /* @__PURE__ */ React.createElement("a", { href: props.dashboardUrl, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-left fa-fw text-[11px]" }), "Dashboard") : null, props.historyUrl ? /* @__PURE__ */ React.createElement("a", { href: props.historyUrl, className: "inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 font-semibold text-sky-100 transition hover:bg-sky-400/15" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-timeline fa-fw text-[11px]" }), "History") : null, collection.manage_url ? /* @__PURE__ */ React.createElement("a", { href: collection.manage_url, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-pen-to-square fa-fw text-[11px]" }), "Manage") : null), /* @__PURE__ */ React.createElement("section", { className: "mt-6 rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Performance"), /* @__PURE__ */ React.createElement("h1", { className: "mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl" }, collection.title || "Collection analytics"), /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]" }, "Review activity velocity, audience response, and the artworks carrying the most discovery value over the last ", range2.days || 30, " days.")), /* @__PURE__ */ React.createElement("section", { className: "mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3" }, /* @__PURE__ */ React.createElement(MetricCard$1, { label: "Views", value: totals.views, delta: range2.views_delta, icon: "fa-eye" }), /* @__PURE__ */ React.createElement(MetricCard$1, { label: "Likes", value: totals.likes, delta: range2.likes_delta, icon: "fa-heart" }), /* @__PURE__ */ React.createElement(MetricCard$1, { label: "Follows", value: totals.follows, delta: range2.follows_delta, icon: "fa-bell" }), /* @__PURE__ */ React.createElement(MetricCard$1, { label: "Saves", value: totals.saves, delta: range2.saves_delta, icon: "fa-bookmark" }), /* @__PURE__ */ React.createElement(MetricCard$1, { label: "Comments", value: totals.comments, delta: range2.comments_delta, icon: "fa-comments" }), /* @__PURE__ */ React.createElement(MetricCard$1, { label: "Submissions", value: totals.submissions, delta: totals.submissions, icon: "fa-inbox" })), /* @__PURE__ */ React.createElement("div", { className: "mt-8 space-y-6" }, /* @__PURE__ */ React.createElement(TimelineChart, { timeline: analytics.timeline }), /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Artworks"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Top artwork drivers")), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300" }, topArtworks.length)), topArtworks.length ? /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4" }, topArtworks.map((artwork) => /* @__PURE__ */ React.createElement("div", { key: artwork.id, className: "overflow-hidden rounded-[24px] border border-white/10 bg-slate-950/40" }, /* @__PURE__ */ React.createElement("div", { className: "aspect-square bg-slate-950/60" }, artwork.thumb ? /* @__PURE__ */ React.createElement("img", { src: artwork.thumb, alt: artwork.title, className: "h-full w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-full w-full items-center justify-center text-slate-500" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-image text-3xl" }))), /* @__PURE__ */ React.createElement("div", { className: "space-y-2 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "truncate text-sm font-semibold text-white" }, artwork.title), /* @__PURE__ */ React.createElement("div", { className: "grid grid-cols-2 gap-2 text-xs text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, "Views: ", Number(artwork.views || 0).toLocaleString()), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, "Favs: ", Number(artwork.favourites || 0).toLocaleString()), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, "Shares: ", Number(artwork.shares || 0).toLocaleString()), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, "Rank: ", Number(artwork.ranking_score || 0).toFixed(1))))))) : /* @__PURE__ */ React.createElement("div", { className: "mt-6 rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300" }, "Attach or publish more artworks before artwork-level ranking can be surfaced here.")))))); } -const __vite_glob_0_30 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_40 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: CollectionAnalytics }, Symbol.toStringTag, { value: "Module" })); @@ -70959,7 +73826,7 @@ function CollectionVisibilityBadge({ visibility, className = "" }) { const style = STYLES[value] || STYLES.public; return /* @__PURE__ */ React.createElement("span", { className: `inline-flex items-center rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] ${style} ${className}`.trim() }, label); } -async function requestJson$m(url, { method = "GET", body: body2 } = {}) { +async function requestJson$o(url, { method = "GET", body: body2 } = {}) { const response = await fetch(url, { method, credentials: "same-origin", @@ -71042,7 +73909,7 @@ function CollectionCard({ collection, isOwner, onDelete, onToggleFeature, onMove } setSaveBusy(true); try { - const payload = await requestJson$m(targetUrl, { + const payload = await requestJson$o(targetUrl, { method: saved ? "DELETE" : "POST", body: saved ? void 0 : { context: saveContext, @@ -71153,7 +74020,7 @@ const DEFAULT_BULK_FORM = { campaign_label: "", lifecycle_state: "archived" }; -function getCsrfToken$c() { +function getCsrfToken$e() { if (typeof document === "undefined") return ""; return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } @@ -71185,14 +74052,14 @@ async function fetchSearchResults(baseUrl, filters) { } return payload; } -async function requestJson$l(url, { method = "POST", body: body2 } = {}) { +async function requestJson$n(url, { method = "POST", body: body2 } = {}) { const response = await fetch(url, { method, credentials: "same-origin", headers: { Accept: "application/json", "Content-Type": "application/json", - "X-CSRF-TOKEN": getCsrfToken$c(), + "X-CSRF-TOKEN": getCsrfToken$e(), "X-Requested-With": "XMLHttpRequest" }, body: body2 ? JSON.stringify(body2) : void 0 @@ -71377,7 +74244,7 @@ function CollectionDashboard() { } setBulkState({ busy: true, error: "", notice: "" }); try { - const response = await requestJson$l(endpoints.bulkActions, { method: "POST", body: payload }); + const response = await requestJson$n(endpoints.bulkActions, { method: "POST", body: payload }); const updates = new Map((Array.isArray(response.collections) ? response.collections : []).map((collection) => [Number(collection.id), collection])); setSearchState((current) => ({ ...current, @@ -71406,7 +74273,7 @@ function CollectionDashboard() { } ), /* @__PURE__ */ React.createElement(SearchResults, { state: searchState, endpoints, selectedIds, onToggleSelected: toggleSelected })), /* @__PURE__ */ React.createElement(WarningList, { warnings: healthWarnings, endpoints }), /* @__PURE__ */ React.createElement(CollectionStrip, { title: "Top Performing", eyebrow: "Momentum", collections: topPerforming, emptyLabel: "No collections have enough activity yet to rank here.", endpoints }), /* @__PURE__ */ React.createElement(CollectionStrip, { title: "Needs Attention", eyebrow: "Quality", collections: needsAttention, emptyLabel: "No collections currently need manual intervention.", endpoints }), /* @__PURE__ */ React.createElement(CollectionStrip, { title: "Expiring Campaigns", eyebrow: "Timing", collections: expiringCampaigns, emptyLabel: "No campaigns are approaching their sunset window.", endpoints }))))); } -const __vite_glob_0_31 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_41 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: CollectionDashboard }, Symbol.toStringTag, { value: "Module" })); @@ -71602,11 +74469,11 @@ function CollectionFeaturedIndex() { } ), /* @__PURE__ */ React.createElement("div", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 -z-10 opacity-[0.05]", style: { backgroundImage: "url(/gfx/noise.png)", backgroundSize: "180px" } }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-7xl px-4 pt-8 md:px-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("a", { href: "/", className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-left fa-fw text-[11px]" }), "Back to home"), /* @__PURE__ */ React.createElement("a", { href: "/collections/featured", className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white" }, "Featured"), /* @__PURE__ */ React.createElement("a", { href: "/collections/trending", className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white" }, "Trending"), /* @__PURE__ */ React.createElement("a", { href: "/collections/community", className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white" }, "Community"), /* @__PURE__ */ React.createElement("a", { href: "/collections/editorial", className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white" }, "Editorial"), /* @__PURE__ */ React.createElement("a", { href: "/collections/seasonal", className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white" }, "Seasonal")), /* @__PURE__ */ React.createElement("section", { className: "mt-6 overflow-hidden rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-8 xl:grid-cols-[minmax(0,1.18fr)_400px] xl:items-end" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, eyebrow), /* @__PURE__ */ React.createElement("h1", { className: "mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl" }, title), campaign?.badge_label ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 inline-flex items-center rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100" }, campaign.badge_label) : program?.promotion_tier ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 inline-flex items-center rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100" }, "Promotion tier: ", program.promotion_tier) : null, /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]" }, description), campaign ? /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-3 text-xs text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-2" }, "Campaign key: ", campaign.key), campaign.event_label ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-2" }, "Event: ", campaign.event_label) : null, campaign.season_key ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-2" }, "Season: ", campaign.season_key) : null, Array.isArray(campaign.active_surface_keys) && campaign.active_surface_keys.length ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-2" }, "Surfaces: ", campaign.active_surface_keys.join(", ")) : null) : program ? /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-3 text-xs text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-2" }, "Program key: ", program.key), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-2" }, "Collections: ", program.collections_count ?? collections.length), program.trust_tier ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-2" }, "Trust: ", program.trust_tier) : null, Array.isArray(program.partner_labels) && program.partner_labels.length ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-2" }, "Partners: ", program.partner_labels.join(", ")) : null, Array.isArray(program.sponsorship_labels) && program.sponsorship_labels.length ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-2" }, "Sponsors: ", program.sponsorship_labels.join(", ")) : null) : null), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 sm:grid-cols-3 xl:grid-cols-1" }, /* @__PURE__ */ React.createElement(HeroStat$2, { icon: "fa-layer-group", label: "Collections", value: collections.length.toLocaleString() }), /* @__PURE__ */ React.createElement(HeroStat$2, { icon: "fa-wand-magic-sparkles", label: "Smart", value: smartCount.toLocaleString() }), /* @__PURE__ */ React.createElement(HeroStat$2, { icon: "fa-images", label: "Artworks", value: totalArtworks.toLocaleString() })))), /* @__PURE__ */ React.createElement("section", { className: "mt-8" }, /* @__PURE__ */ React.createElement(SearchPanel, { search: search2 })), /* @__PURE__ */ React.createElement("section", { className: "mt-8" }, collections.length ? /* @__PURE__ */ React.createElement("div", { className: "grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3" }, collections.map((collection) => /* @__PURE__ */ React.createElement(CollectionCard, { key: collection.id, collection, isOwner: false, saveContext: mainSave.context, saveContextMeta: mainSave.meta }))) : /* @__PURE__ */ React.createElement(EmptyState$4, null)), communityCollections.length ? /* @__PURE__ */ React.createElement("section", { className: "mt-10" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-end justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Community"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Collaborative picks"))), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3" }, communityCollections.map((collection) => /* @__PURE__ */ React.createElement(CollectionCard, { key: collection.id, collection, isOwner: false, saveContext: "community_row", saveContextMeta: { surface_label: "community collections" } })))) : null, trendingCollections.length ? /* @__PURE__ */ React.createElement("section", { className: "mt-10" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Trending"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Momentum right now")), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3" }, trendingCollections.map((collection) => /* @__PURE__ */ React.createElement(CollectionCard, { key: collection.id, collection, isOwner: false, saveContext: "trending_row", saveContextMeta: { surface_label: "trending collections" } })))) : null, editorialCollections.length ? /* @__PURE__ */ React.createElement("section", { className: "mt-10" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Editorial"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Staff and campaign collections")), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3" }, editorialCollections.map((collection) => /* @__PURE__ */ React.createElement(CollectionCard, { key: collection.id, collection, isOwner: false, saveContext: "editorial_row", saveContextMeta: { surface_label: "editorial collections" } })))) : null, seasonalCollections.length ? /* @__PURE__ */ React.createElement("section", { className: "mt-10" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Seasonal"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Campaign and event spotlights")), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3" }, seasonalCollections.map((collection) => /* @__PURE__ */ React.createElement(CollectionCard, { key: collection.id, collection, isOwner: false, saveContext: "seasonal_row", saveContextMeta: { surface_label: "seasonal collections" } })))) : null, recentCollections.length ? /* @__PURE__ */ React.createElement("section", { className: "mt-10 pb-8" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Recent"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Freshly published collections")), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3" }, recentCollections.map((collection) => /* @__PURE__ */ React.createElement(CollectionCard, { key: collection.id, collection, isOwner: false, saveContext: "recent_row", saveContextMeta: { surface_label: "recent collections" } })))) : null))); } -const __vite_glob_0_32 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_42 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: CollectionFeaturedIndex }, Symbol.toStringTag, { value: "Module" })); -function getCsrfToken$b() { +function getCsrfToken$d() { return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } function formatDateTime$2(value) { @@ -71647,7 +74514,7 @@ function CollectionHistory() { method: "POST", headers: { Accept: "application/json", - "X-CSRF-TOKEN": getCsrfToken$b() + "X-CSRF-TOKEN": getCsrfToken$d() } }); const payload = await response.json().catch(() => ({})); @@ -71673,11 +74540,11 @@ function CollectionHistory() { busyId === entry.id ? "Restoring…" : "Restore" ) : null)), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-4 lg:grid-cols-2" }, /* @__PURE__ */ React.createElement(FieldChanges, { label: "Before", value: entry.before }), /* @__PURE__ */ React.createElement(FieldChanges, { label: "After", value: entry.after })))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[30px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-14 text-sm text-slate-300" }, "No audit entries have been recorded for this collection yet.")), Number(meta.last_page || 1) > 1 ? /* @__PURE__ */ React.createElement("div", { className: "mt-8 flex flex-wrap items-center justify-between gap-3 rounded-[28px] border border-white/10 bg-white/[0.04] px-5 py-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", null, "Page ", meta.current_page || 1, " of ", meta.last_page || 1), /* @__PURE__ */ React.createElement("div", { className: "flex gap-2" }, (meta.current_page || 1) > 1 ? /* @__PURE__ */ React.createElement("a", { href: buildPageUrl((meta.current_page || 1) - 1), className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-left fa-fw text-[10px]" }), "Previous") : null, (meta.current_page || 1) < (meta.last_page || 1) ? /* @__PURE__ */ React.createElement("a", { href: buildPageUrl((meta.current_page || 1) + 1), className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 font-semibold text-white transition hover:bg-white/[0.07]" }, "Next", /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-right fa-fw text-[10px]" })) : null)) : null))); } -const __vite_glob_0_33 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_43 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: CollectionHistory }, Symbol.toStringTag, { value: "Module" })); -function getCsrfToken$a() { +function getCsrfToken$c() { if (typeof document === "undefined") return ""; return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } @@ -71789,14 +74656,14 @@ function firstEntitySelection(options) { id: options?.[firstType]?.[0]?.id || "" }; } -async function requestJson$k(url, { method = "GET", body: body2 } = {}) { +async function requestJson$m(url, { method = "GET", body: body2 } = {}) { const response = await fetch(url, { method, credentials: "same-origin", headers: { Accept: "application/json", "Content-Type": "application/json", - "X-CSRF-TOKEN": getCsrfToken$a(), + "X-CSRF-TOKEN": getCsrfToken$c(), "X-Requested-With": "XMLHttpRequest" }, body: body2 ? JSON.stringify(body2) : void 0 @@ -71935,7 +74802,7 @@ function buildRuleSummary(rule, smartRuleOptions) { const value = String(rule.value || "").trim() || "Any value"; return `${label} ${rule.operator} ${value}`; } -function Field$2({ label, children, help }) { +function Field$3({ label, children, help }) { return /* @__PURE__ */ React.createElement("label", { className: "block space-y-2" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400" }, label), children, help ? /* @__PURE__ */ React.createElement("p", { className: "text-xs text-slate-500" }, help) : null); } function StatCard$7({ icon, label, value, tone = "default" }) { @@ -72040,7 +74907,7 @@ function LayoutModuleCard({ module, index: index2, total, onToggle, onSlotChange onChange: (event) => onToggle(module.key, event.target.checked), label: module.locked ? "Required" : module.enabled ? "Enabled" : "Disabled" } - ))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Placement" }, /* @__PURE__ */ React.createElement( + ))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]" }, /* @__PURE__ */ React.createElement(Field$3, { label: "Placement" }, /* @__PURE__ */ React.createElement( NovaSelect, { value: module.slot, @@ -72119,7 +74986,7 @@ function SmartRuleRow({ }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-trash-can fa-fw" }), "Remove" - )), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-4 md:grid-cols-[1fr_180px_minmax(0,1.15fr)]" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Field" }, /* @__PURE__ */ React.createElement( + )), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-4 md:grid-cols-[1fr_180px_minmax(0,1.15fr)]" }, /* @__PURE__ */ React.createElement(Field$3, { label: "Field" }, /* @__PURE__ */ React.createElement( NovaSelect, { value: rule.field, @@ -72127,7 +74994,7 @@ function SmartRuleRow({ options: fieldOptions.map((option) => ({ value: option.value, label: option.label })), searchable: false } - )), /* @__PURE__ */ React.createElement(Field$2, { label: "Operator" }, /* @__PURE__ */ React.createElement( + )), /* @__PURE__ */ React.createElement(Field$3, { label: "Operator" }, /* @__PURE__ */ React.createElement( NovaSelect, { value: rule.operator, @@ -72135,7 +75002,7 @@ function SmartRuleRow({ options: operatorOptions.map((option) => ({ value: option.value, label: option.label })), searchable: false } - )), rule.field === "created_at" ? /* @__PURE__ */ React.createElement(Field$2, { label: "Date Range" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 sm:grid-cols-2" }, /* @__PURE__ */ React.createElement( + )), rule.field === "created_at" ? /* @__PURE__ */ React.createElement(Field$3, { label: "Date Range" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 sm:grid-cols-2" }, /* @__PURE__ */ React.createElement( DateTimePicker, { value: rule.value?.from || "", @@ -72155,7 +75022,7 @@ function SmartRuleRow({ clearable: true, className: "bg-white/[0.04]" } - ))) : rule.field === "is_featured" || rule.field === "is_mature" ? /* @__PURE__ */ React.createElement(Field$2, { label: "Value" }, /* @__PURE__ */ React.createElement( + ))) : rule.field === "is_featured" || rule.field === "is_mature" ? /* @__PURE__ */ React.createElement(Field$3, { label: "Value" }, /* @__PURE__ */ React.createElement( NovaSelect, { value: rule.value ? "true" : "false", @@ -72169,7 +75036,7 @@ function SmartRuleRow({ ], searchable: false } - )) : rule.field === "tags" ? /* @__PURE__ */ React.createElement(Field$2, { label: "Value", help: "Type a tag name exactly as it appears on your artworks." }, /* @__PURE__ */ React.createElement( + )) : rule.field === "tags" ? /* @__PURE__ */ React.createElement(Field$3, { label: "Value", help: "Type a tag name exactly as it appears on your artworks." }, /* @__PURE__ */ React.createElement( "input", { type: "text", @@ -72178,7 +75045,7 @@ function SmartRuleRow({ className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", placeholder: "e.g. dark-fantasy" } - )) : valueOptions.length ? /* @__PURE__ */ React.createElement(Field$2, { label: "Value" }, /* @__PURE__ */ React.createElement( + )) : valueOptions.length ? /* @__PURE__ */ React.createElement(Field$3, { label: "Value" }, /* @__PURE__ */ React.createElement( NovaSelect, { value: rule.value, @@ -72187,7 +75054,7 @@ function SmartRuleRow({ placeholder: "Select one", searchable: false } - )) : /* @__PURE__ */ React.createElement(Field$2, { label: "Value" }, /* @__PURE__ */ React.createElement( + )) : /* @__PURE__ */ React.createElement(Field$3, { label: "Value" }, /* @__PURE__ */ React.createElement( "input", { type: "text", @@ -72550,7 +75417,7 @@ function CollectionManage() { setErrors({}); setNotice(""); try { - const payload = await requestJson$k(mode === "create" ? endpoints.store : endpoints.update, { + const payload = await requestJson$m(mode === "create" ? endpoints.store : endpoints.update, { method: mode === "create" ? "POST" : "PATCH", body: buildPayload2() }); @@ -72585,7 +75452,7 @@ function CollectionManage() { setPreviewing(true); setErrors({}); try { - const payload = await requestJson$k(endpoints.smartPreview, { + const payload = await requestJson$m(endpoints.smartPreview, { method: "POST", body: { smart_rules_json: { @@ -72608,7 +75475,7 @@ function CollectionManage() { setSearching(true); try { const url = `${endpoints.available}?search=${encodeURIComponent(search2)}`; - const payload = await requestJson$k(url); + const payload = await requestJson$m(url); setAvailable(payload?.data || []); } catch (error) { setErrors(error?.payload?.errors || { form: [error.message] }); @@ -72633,7 +75500,7 @@ function CollectionManage() { if (!url) return; setAiState((current) => ({ ...current, busy: kind })); try { - const payload = await requestJson$k(url, { + const payload = await requestJson$m(url, { method: "POST", body: { draft: buildPayload2() } }); @@ -72651,7 +75518,7 @@ function CollectionManage() { if (!endpoints?.aiQualityReview) return; setAiState((current) => ({ ...current, busy: "qualityReview" })); try { - const payload = await requestJson$k(endpoints.aiQualityReview); + const payload = await requestJson$m(endpoints.aiQualityReview); setAiState((current) => ({ ...current, busy: "", @@ -72688,7 +75555,7 @@ function CollectionManage() { setSaving(true); setErrors({}); try { - const payload = await requestJson$k(endpoints.attach, { + const payload = await requestJson$m(endpoints.attach, { method: "POST", body: { artwork_ids: selectedIds } }); @@ -72709,7 +75576,7 @@ function CollectionManage() { setSaving(true); setErrors({}); try { - const payload = await requestJson$k(artwork.remove_url, { method: "DELETE" }); + const payload = await requestJson$m(artwork.remove_url, { method: "DELETE" }); setCollectionState(payload.collection); setAttached(payload.attachedArtworks || []); setAvailable(payload.availableArtworks || []); @@ -72741,7 +75608,7 @@ function CollectionManage() { setSaving(true); setErrors({}); try { - const payload = await requestJson$k(endpoints.reorder, { + const payload = await requestJson$m(endpoints.reorder, { method: "POST", body: { ordered_artwork_ids: attached.map((artwork) => artwork.id) } }); @@ -72762,7 +75629,7 @@ function CollectionManage() { setFeatureBusy(true); setErrors({}); try { - const payload = await requestJson$k(url, { + const payload = await requestJson$m(url, { method: isFeatured ? "DELETE" : "POST" }); if (payload.collection) { @@ -72780,7 +75647,7 @@ function CollectionManage() { setSaving(true); setErrors({}); try { - const payload = await requestJson$k(endpoints.syncLinkedCollections, { + const payload = await requestJson$m(endpoints.syncLinkedCollections, { method: "POST", body: { related_collection_ids: nextIds @@ -72823,7 +75690,7 @@ function CollectionManage() { setSaving(true); setErrors({}); try { - const payload = await requestJson$k(endpoints.syncEntityLinks, { + const payload = await requestJson$m(endpoints.syncEntityLinks, { method: "POST", body: { entity_links: nextLinks @@ -72899,7 +75766,7 @@ function CollectionManage() { setSaving(true); setErrors({}); try { - const payload = await requestJson$k(endpoints.canonicalize, { + const payload = await requestJson$m(endpoints.canonicalize, { method: "POST", body: { target_collection_id: targetId } }); @@ -72917,7 +75784,7 @@ function CollectionManage() { setSaving(true); setErrors({}); try { - const payload = await requestJson$k(endpoints.merge, { + const payload = await requestJson$m(endpoints.merge, { method: "POST", body: { target_collection_id: targetId } }); @@ -72935,7 +75802,7 @@ function CollectionManage() { setSaving(true); setErrors({}); try { - const payload = await requestJson$k(endpoints.rejectDuplicate, { + const payload = await requestJson$m(endpoints.rejectDuplicate, { method: "POST", body: { target_collection_id: targetId } }); @@ -72951,7 +75818,7 @@ function CollectionManage() { if (!window.confirm("Delete this collection? Artworks will remain untouched.")) return; setSaving(true); try { - const payload = await requestJson$k(endpoints.delete, { method: "DELETE" }); + const payload = await requestJson$m(endpoints.delete, { method: "DELETE" }); window.location.assign(payload.redirect); } catch (error) { setErrors(error?.payload?.errors || { form: [error.message] }); @@ -72975,7 +75842,7 @@ function CollectionManage() { setSaving(true); setErrors({}); try { - const payload = await requestJson$k(endpoints.inviteMember, { + const payload = await requestJson$m(endpoints.inviteMember, { method: "POST", body: body2 }); @@ -72993,7 +75860,7 @@ function CollectionManage() { async function handleMemberRoleChange(member, role) { const url = endpoints?.memberUpdatePattern?.replace("__MEMBER__", member.id); if (!url) return; - const payload = await requestJson$k(url, { + const payload = await requestJson$m(url, { method: "PATCH", body: { role } }); @@ -73002,14 +75869,14 @@ function CollectionManage() { async function handleRemoveMember(member) { const url = endpoints?.memberDeletePattern?.replace("__MEMBER__", member.id); if (!url) return; - const payload = await requestJson$k(url, { method: "DELETE" }); + const payload = await requestJson$m(url, { method: "DELETE" }); setMembers(payload?.members || []); } async function handleTransferMember(member) { const url = endpoints?.memberTransferPattern?.replace("__MEMBER__", member.id); if (!url) return; if (!window.confirm(`Transfer collection ownership to @${member?.user?.username}? You will keep editor access.`)) return; - const payload = await requestJson$k(url, { method: "POST" }); + const payload = await requestJson$m(url, { method: "POST" }); applyCollectionPayload(payload?.collection); setMembers(payload?.members || []); setNotice(`Ownership transferred to @${member?.user?.username}.`); @@ -73017,24 +75884,24 @@ function CollectionManage() { async function handleAcceptMember(member) { const url = endpoints?.acceptMemberPattern?.replace("__MEMBER__", member.id); if (!url) return; - const payload = await requestJson$k(url, { method: "POST" }); + const payload = await requestJson$m(url, { method: "POST" }); setMembers(payload?.members || []); } async function handleDeclineMember(member) { const url = endpoints?.declineMemberPattern?.replace("__MEMBER__", member.id); if (!url) return; - const payload = await requestJson$k(url, { method: "POST" }); + const payload = await requestJson$m(url, { method: "POST" }); setMembers(payload?.members || []); } async function handleSubmissionAction(submission, action) { const url = action === "approve" ? endpoints?.submissionApprovePattern?.replace("__SUBMISSION__", submission.id) : action === "reject" ? endpoints?.submissionRejectPattern?.replace("__SUBMISSION__", submission.id) : endpoints?.submissionDeletePattern?.replace("__SUBMISSION__", submission.id); if (!url) return; - const payload = await requestJson$k(url, { method: action === "withdraw" ? "DELETE" : "POST" }); + const payload = await requestJson$m(url, { method: action === "withdraw" ? "DELETE" : "POST" }); setSubmissions(payload?.submissions || []); } async function handleModerationStatusChange(value) { if (!endpoints?.adminModerationUpdate) return; - const payload = await requestJson$k(endpoints.adminModerationUpdate, { + const payload = await requestJson$m(endpoints.adminModerationUpdate, { method: "PATCH", body: { moderation_status: value } }); @@ -73043,7 +75910,7 @@ function CollectionManage() { } async function handleModerationToggle(key, value) { if (!endpoints?.adminInteractionsUpdate) return; - const payload = await requestJson$k(endpoints.adminInteractionsUpdate, { + const payload = await requestJson$m(endpoints.adminInteractionsUpdate, { method: "PATCH", body: { [key]: value } }); @@ -73052,7 +75919,7 @@ function CollectionManage() { } async function handleAdminUnfeature() { if (!endpoints?.adminUnfeature) return; - const payload = await requestJson$k(endpoints.adminUnfeature, { + const payload = await requestJson$m(endpoints.adminUnfeature, { method: "POST" }); applyCollectionPayload(payload?.collection); @@ -73061,7 +75928,7 @@ function CollectionManage() { async function handleAdminRemoveMember(member) { const url = endpoints?.adminMemberRemovePattern?.replace("__MEMBER__", member.id); if (!url) return; - const payload = await requestJson$k(url, { method: "DELETE" }); + const payload = await requestJson$m(url, { method: "DELETE" }); applyCollectionPayload(payload?.collection); setMembers(payload?.members || []); setNotice("Collaborator removed by moderation action."); @@ -73102,7 +75969,7 @@ function CollectionManage() { icon: "fa-wand-magic-sparkles", onClick: () => updateForm("mode", "smart") } - )), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Title" }, /* @__PURE__ */ React.createElement( + )), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$3, { label: "Title" }, /* @__PURE__ */ React.createElement( "input", { type: "text", @@ -73112,7 +75979,7 @@ function CollectionManage() { placeholder: "Dark Fantasy Series", maxLength: 120 } - )), /* @__PURE__ */ React.createElement(Field$2, { label: "Visibility" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.visibility, onChange: (val) => updateForm("visibility", val), searchable: false, options: [{ value: "public", label: "Public — visible to everyone" }, { value: "unlisted", label: "Unlisted — accessible by link only" }, { value: "private", label: "Private — only you can see it" }] }))), /* @__PURE__ */ React.createElement(Field$2, { label: "URL Slug", help: "Auto-generated from the title. Edit to customise the collection URL." }, /* @__PURE__ */ React.createElement( + )), /* @__PURE__ */ React.createElement(Field$3, { label: "Visibility" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.visibility, onChange: (val) => updateForm("visibility", val), searchable: false, options: [{ value: "public", label: "Public — visible to everyone" }, { value: "unlisted", label: "Unlisted — accessible by link only" }, { value: "private", label: "Private — only you can see it" }] }))), /* @__PURE__ */ React.createElement(Field$3, { label: "URL Slug", help: "Auto-generated from the title. Edit to customise the collection URL." }, /* @__PURE__ */ React.createElement( "input", { type: "text", @@ -73125,7 +75992,7 @@ function CollectionManage() { placeholder: "dark-fantasy-series", maxLength: 140 } - )), /* @__PURE__ */ React.createElement(Field$2, { label: "Summary", help: "A short line shown on collection cards and in search results." }, /* @__PURE__ */ React.createElement( + )), /* @__PURE__ */ React.createElement(Field$3, { label: "Summary", help: "A short line shown on collection cards and in search results." }, /* @__PURE__ */ React.createElement( "input", { type: "text", @@ -73135,7 +76002,7 @@ function CollectionManage() { placeholder: "Best performing sci-fi wallpapers from the last year", maxLength: 320 } - )), /* @__PURE__ */ React.createElement(AdvancedSection, { title: "Description & Presentation", icon: "fa-palette", defaultOpen: mode === "edit" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Subtitle", help: "Optional short line that sits under the title." }, /* @__PURE__ */ React.createElement( + )), /* @__PURE__ */ React.createElement(AdvancedSection, { title: "Description & Presentation", icon: "fa-palette", defaultOpen: mode === "edit" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$3, { label: "Subtitle", help: "Optional short line that sits under the title." }, /* @__PURE__ */ React.createElement( "input", { type: "text", @@ -73145,7 +76012,7 @@ function CollectionManage() { placeholder: "A moody archive of midnight environments", maxLength: 160 } - )), /* @__PURE__ */ React.createElement(Field$2, { label: "Presentation Style" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.presentation_style, onChange: (val) => updateForm("presentation_style", val), searchable: false, options: [{ value: "standard", label: "Standard" }, { value: "editorial_grid", label: "Editorial Grid" }, { value: "hero_grid", label: "Hero Grid" }, { value: "masonry", label: "Masonry" }] }))), /* @__PURE__ */ React.createElement(Field$2, { label: "Description", help: "Describe the mood, focus, or story behind this showcase." }, /* @__PURE__ */ React.createElement( + )), /* @__PURE__ */ React.createElement(Field$3, { label: "Presentation Style" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.presentation_style, onChange: (val) => updateForm("presentation_style", val), searchable: false, options: [{ value: "standard", label: "Standard" }, { value: "editorial_grid", label: "Editorial Grid" }, { value: "hero_grid", label: "Hero Grid" }, { value: "masonry", label: "Masonry" }] }))), /* @__PURE__ */ React.createElement(Field$3, { label: "Description", help: "Describe the mood, focus, or story behind this showcase." }, /* @__PURE__ */ React.createElement( "textarea", { value: form.description, @@ -73154,7 +76021,7 @@ function CollectionManage() { placeholder: "A curated selection of pieces that share a common visual language…", maxLength: 1e3 } - )), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Emphasis Mode" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.emphasis_mode, onChange: (val) => updateForm("emphasis_mode", val), searchable: false, options: [{ value: "cover_heavy", label: "Cover Heavy" }, { value: "balanced", label: "Balanced" }, { value: "artwork_first", label: "Artwork First" }] })), /* @__PURE__ */ React.createElement(Field$2, { label: "Theme" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.theme_token, onChange: (val) => updateForm("theme_token", val), searchable: false, options: [{ value: "default", label: "Default" }, { value: "subtle-blue", label: "Subtle Blue" }, { value: "violet", label: "Violet" }, { value: "amber", label: "Amber" }] }))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, !isSmartMode ? /* @__PURE__ */ React.createElement(Field$2, { label: "Sort Order", help: "Manual keeps the display order under your direct control." }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.sort_mode, onChange: (val) => updateForm("sort_mode", val), searchable: false, options: [{ value: "manual", label: "Manual" }, { value: "newest", label: "Newest first" }, { value: "oldest", label: "Oldest first" }, { value: "popular", label: "Most popular" }] })) : /* @__PURE__ */ React.createElement(Field$2, { label: "Match Mode", help: "All rules must match, or any one rule is enough." }, /* @__PURE__ */ React.createElement( + )), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$3, { label: "Emphasis Mode" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.emphasis_mode, onChange: (val) => updateForm("emphasis_mode", val), searchable: false, options: [{ value: "cover_heavy", label: "Cover Heavy" }, { value: "balanced", label: "Balanced" }, { value: "artwork_first", label: "Artwork First" }] })), /* @__PURE__ */ React.createElement(Field$3, { label: "Theme" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.theme_token, onChange: (val) => updateForm("theme_token", val), searchable: false, options: [{ value: "default", label: "Default" }, { value: "subtle-blue", label: "Subtle Blue" }, { value: "violet", label: "Violet" }, { value: "amber", label: "Amber" }] }))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, !isSmartMode ? /* @__PURE__ */ React.createElement(Field$3, { label: "Sort Order", help: "Manual keeps the display order under your direct control." }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.sort_mode, onChange: (val) => updateForm("sort_mode", val), searchable: false, options: [{ value: "manual", label: "Manual" }, { value: "newest", label: "Newest first" }, { value: "oldest", label: "Oldest first" }, { value: "popular", label: "Most popular" }] })) : /* @__PURE__ */ React.createElement(Field$3, { label: "Match Mode", help: "All rules must match, or any one rule is enough." }, /* @__PURE__ */ React.createElement( NovaSelect, { value: smartRules.match, @@ -73162,7 +76029,7 @@ function CollectionManage() { searchable: false, options: [{ value: "all", label: "All rules" }, { value: "any", label: "Any rule" }] } - )), !isSmartMode ? /* @__PURE__ */ React.createElement(Field$2, { label: "Cover Artwork", help: attachedCoverOptions.length ? "Choose a cover from artworks already attached to this collection." : "Attach artworks first to pick a manual cover." }, /* @__PURE__ */ React.createElement( + )), !isSmartMode ? /* @__PURE__ */ React.createElement(Field$3, { label: "Cover Artwork", help: attachedCoverOptions.length ? "Choose a cover from artworks already attached to this collection." : "Attach artworks first to pick a manual cover." }, /* @__PURE__ */ React.createElement( NovaSelect, { value: String(form.cover_artwork_id || ""), @@ -73171,7 +76038,7 @@ function CollectionManage() { placeholder: "Automatic cover", options: attachedCoverOptions.map((a) => ({ value: String(a.id), label: a.title })) } - )) : /* @__PURE__ */ React.createElement(Field$2, { label: "Smart Sort", help: "How matching artworks should be ordered in this collection." }, /* @__PURE__ */ React.createElement( + )) : /* @__PURE__ */ React.createElement(Field$3, { label: "Smart Sort", help: "How matching artworks should be ordered in this collection." }, /* @__PURE__ */ React.createElement( NovaSelect, { value: smartRules.sort, @@ -73181,7 +76048,7 @@ function CollectionManage() { }, options: smartRuleOptions?.sort_options || [] } - )))), /* @__PURE__ */ React.createElement(AdvancedSection, { title: "Collaboration & Access", icon: "fa-user-group", defaultOpen: mode === "edit" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Collection Type" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.type, onChange: (val) => updateForm("type", val), searchable: false, options: [{ value: "personal", label: "Personal" }, { value: "community", label: "Community" }, { value: "editorial", label: "Editorial" }] })), /* @__PURE__ */ React.createElement(Field$2, { label: "Collaboration Mode" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.collaboration_mode, onChange: (val) => updateForm("collaboration_mode", val), searchable: false, options: [{ value: "closed", label: "Closed — curated by you only" }, { value: "invite_only", label: "Invite only" }, { value: "open", label: "Open submissions" }] }))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 md:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: form.allow_submissions, onChange: (event) => updateForm("allow_submissions", event.target.checked), label: "Allow submissions" })), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: form.allow_comments, onChange: (event) => updateForm("allow_comments", event.target.checked), label: "Allow comments" })), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: form.allow_saves, onChange: (event) => updateForm("allow_saves", event.target.checked), label: "Allow saves" })), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: form.commercial_eligibility, onChange: (event) => updateForm("commercial_eligibility", event.target.checked), label: "Commercially eligible" }))), form.type === "editorial" ? /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-3" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Editorial Owner", help: "Choose whether this editorial lives under the current curator, another staff account, or the system identity." }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.editorial_owner_mode, onChange: (val) => updateForm("editorial_owner_mode", val), searchable: false, options: [{ value: "creator", label: "Current curator" }, { value: "staff_account", label: "Staff account" }, { value: "system", label: "System editorial identity" }] })), form.editorial_owner_mode === "staff_account" ? /* @__PURE__ */ React.createElement(Field$2, { label: "Staff Account Username", help: "Must be an admin or moderator username." }, /* @__PURE__ */ React.createElement( + )))), /* @__PURE__ */ React.createElement(AdvancedSection, { title: "Collaboration & Access", icon: "fa-user-group", defaultOpen: mode === "edit" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$3, { label: "Collection Type" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.type, onChange: (val) => updateForm("type", val), searchable: false, options: [{ value: "personal", label: "Personal" }, { value: "community", label: "Community" }, { value: "editorial", label: "Editorial" }] })), /* @__PURE__ */ React.createElement(Field$3, { label: "Collaboration Mode" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.collaboration_mode, onChange: (val) => updateForm("collaboration_mode", val), searchable: false, options: [{ value: "closed", label: "Closed — curated by you only" }, { value: "invite_only", label: "Invite only" }, { value: "open", label: "Open submissions" }] }))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 md:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: form.allow_submissions, onChange: (event) => updateForm("allow_submissions", event.target.checked), label: "Allow submissions" })), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: form.allow_comments, onChange: (event) => updateForm("allow_comments", event.target.checked), label: "Allow comments" })), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: form.allow_saves, onChange: (event) => updateForm("allow_saves", event.target.checked), label: "Allow saves" })), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: form.commercial_eligibility, onChange: (event) => updateForm("commercial_eligibility", event.target.checked), label: "Commercially eligible" }))), form.type === "editorial" ? /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-3" }, /* @__PURE__ */ React.createElement(Field$3, { label: "Editorial Owner", help: "Choose whether this editorial lives under the current curator, another staff account, or the system identity." }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.editorial_owner_mode, onChange: (val) => updateForm("editorial_owner_mode", val), searchable: false, options: [{ value: "creator", label: "Current curator" }, { value: "staff_account", label: "Staff account" }, { value: "system", label: "System editorial identity" }] })), form.editorial_owner_mode === "staff_account" ? /* @__PURE__ */ React.createElement(Field$3, { label: "Staff Account Username", help: "Must be an admin or moderator username." }, /* @__PURE__ */ React.createElement( "input", { type: "text", @@ -73191,7 +76058,7 @@ function CollectionManage() { placeholder: "skinbase-editorial", maxLength: 60 } - )) : null, form.editorial_owner_mode === "system" ? /* @__PURE__ */ React.createElement(Field$2, { label: "System Owner Label", help: "Public-facing label for system-owned editorials." }, /* @__PURE__ */ React.createElement( + )) : null, form.editorial_owner_mode === "system" ? /* @__PURE__ */ React.createElement(Field$3, { label: "System Owner Label", help: "Public-facing label for system-owned editorials." }, /* @__PURE__ */ React.createElement( "input", { type: "text", @@ -73201,7 +76068,7 @@ function CollectionManage() { placeholder: "Skinbase Editorial", maxLength: 120 } - )) : null) : null), /* @__PURE__ */ React.createElement(AdvancedSection, { title: "Campaign & Events", icon: "fa-bullhorn", defaultOpen: mode === "edit" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Event Key", help: "Internal identifier used by discovery and promotion logic." }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.event_key, onChange: (event) => updateForm("event_key", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Event Label" }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.event_label, onChange: (event) => updateForm("event_label", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 120 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Season Key", help: "Groups related collections by season on landing surfaces." }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.season_key, onChange: (event) => updateForm("season_key", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Badge Label", help: "Short public badge on cards and headers." }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.badge_label, onChange: (event) => updateForm("badge_label", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 80 }))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Campaign Key", help: "Operational identifier for recommendation and placement logic." }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.campaign_key, onChange: (event) => updateForm("campaign_key", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Campaign Label", help: "Public-facing campaign or promotion label." }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.campaign_label, onChange: (event) => updateForm("campaign_label", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 120 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Spotlight Style", help: "Controls the visual frame for the public campaign banner." }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.spotlight_style, onChange: (val) => updateForm("spotlight_style", val), searchable: false, options: [{ value: "default", label: "Default" }, { value: "editorial", label: "Editorial" }, { value: "seasonal", label: "Seasonal" }, { value: "challenge", label: "Challenge" }, { value: "community", label: "Community" }] })), /* @__PURE__ */ React.createElement(Field$2, { label: "Banner Text", help: "Short line shown in the collection spotlight banner." }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.banner_text, onChange: (event) => updateForm("banner_text", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 200 })))), /* @__PURE__ */ React.createElement(AdvancedSection, { title: "Series", icon: "fa-layer-group", defaultOpen: mode === "edit" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-3" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Series Key", help: "Use the same key across all linked collections in a series." }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.series_key, onChange: (event) => updateForm("series_key", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Series Title", help: "Optional public heading shown for the whole series." }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.series_title, onChange: (event) => updateForm("series_title", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 160 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Series Order", help: "Sequence position for public next & previous navigation." }, /* @__PURE__ */ React.createElement("input", { type: "number", min: "1", max: "9999", value: form.series_order, onChange: (event) => updateForm("series_order", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" }))), /* @__PURE__ */ React.createElement(Field$2, { label: "Series Description", help: "Optional public intro shown on series landing pages." }, /* @__PURE__ */ React.createElement( + )) : null) : null), /* @__PURE__ */ React.createElement(AdvancedSection, { title: "Campaign & Events", icon: "fa-bullhorn", defaultOpen: mode === "edit" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement(Field$3, { label: "Event Key", help: "Internal identifier used by discovery and promotion logic." }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.event_key, onChange: (event) => updateForm("event_key", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$3, { label: "Event Label" }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.event_label, onChange: (event) => updateForm("event_label", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 120 })), /* @__PURE__ */ React.createElement(Field$3, { label: "Season Key", help: "Groups related collections by season on landing surfaces." }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.season_key, onChange: (event) => updateForm("season_key", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$3, { label: "Badge Label", help: "Short public badge on cards and headers." }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.badge_label, onChange: (event) => updateForm("badge_label", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 80 }))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement(Field$3, { label: "Campaign Key", help: "Operational identifier for recommendation and placement logic." }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.campaign_key, onChange: (event) => updateForm("campaign_key", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$3, { label: "Campaign Label", help: "Public-facing campaign or promotion label." }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.campaign_label, onChange: (event) => updateForm("campaign_label", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 120 })), /* @__PURE__ */ React.createElement(Field$3, { label: "Spotlight Style", help: "Controls the visual frame for the public campaign banner." }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.spotlight_style, onChange: (val) => updateForm("spotlight_style", val), searchable: false, options: [{ value: "default", label: "Default" }, { value: "editorial", label: "Editorial" }, { value: "seasonal", label: "Seasonal" }, { value: "challenge", label: "Challenge" }, { value: "community", label: "Community" }] })), /* @__PURE__ */ React.createElement(Field$3, { label: "Banner Text", help: "Short line shown in the collection spotlight banner." }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.banner_text, onChange: (event) => updateForm("banner_text", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 200 })))), /* @__PURE__ */ React.createElement(AdvancedSection, { title: "Series", icon: "fa-layer-group", defaultOpen: mode === "edit" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-3" }, /* @__PURE__ */ React.createElement(Field$3, { label: "Series Key", help: "Use the same key across all linked collections in a series." }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.series_key, onChange: (event) => updateForm("series_key", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$3, { label: "Series Title", help: "Optional public heading shown for the whole series." }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.series_title, onChange: (event) => updateForm("series_title", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 160 })), /* @__PURE__ */ React.createElement(Field$3, { label: "Series Order", help: "Sequence position for public next & previous navigation." }, /* @__PURE__ */ React.createElement("input", { type: "number", min: "1", max: "9999", value: form.series_order, onChange: (event) => updateForm("series_order", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" }))), /* @__PURE__ */ React.createElement(Field$3, { label: "Series Description", help: "Optional public intro shown on series landing pages." }, /* @__PURE__ */ React.createElement( "textarea", { value: form.series_description, @@ -73209,7 +76076,7 @@ function CollectionManage() { className: "min-h-[96px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 400 } - ))), /* @__PURE__ */ React.createElement(AdvancedSection, { title: "Scheduling & Lifecycle", icon: "fa-calendar-days", defaultOpen: mode === "edit" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Lifecycle State", help: "Draft keeps it hidden. Published makes it live. Archived retires it from active surfaces." }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.lifecycle_state, onChange: (val) => updateForm("lifecycle_state", val), searchable: false, options: [{ value: "draft", label: "Draft" }, { value: "scheduled", label: "Scheduled" }, { value: "published", label: "Published" }, { value: "featured", label: "Featured" }, { value: "archived", label: "Archived" }, { value: "expired", label: "Expired" }] })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Publish At", help: "Leave empty to publish immediately. A future time keeps it off public surfaces until it goes live." }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: form.published_at, onChange: (nextValue) => updateForm("published_at", nextValue), placeholder: "Publish time", clearable: true, className: "bg-white/[0.04]" })), /* @__PURE__ */ React.createElement(Field$2, { label: "Unpublish At", help: "Optional automatic sunset time for seasonal or editorial collections." }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: form.unpublished_at, onChange: (nextValue) => updateForm("unpublished_at", nextValue), placeholder: "Unpublish time", clearable: true, className: "bg-white/[0.04]" }))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Archive At", help: "Optional timestamp for moving the collection to long-term archive workflows." }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: form.archived_at, onChange: (nextValue) => updateForm("archived_at", nextValue), placeholder: "Archive time", clearable: true, className: "bg-white/[0.04]" })), /* @__PURE__ */ React.createElement(Field$2, { label: "Expire At", help: "Optional hard expiry for promotional or seasonal collections." }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: form.expired_at, onChange: (nextValue) => updateForm("expired_at", nextValue), placeholder: "Expiry time", clearable: true, className: "bg-white/[0.04]" })))), /* @__PURE__ */ React.createElement(AdvancedSection, { title: "Commercial & Administration", icon: "fa-briefcase", defaultOpen: mode === "edit" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Promotion Tier" }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.promotion_tier, onChange: (event) => updateForm("promotion_tier", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 40 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Monetization Status" }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.monetization_ready_status, onChange: (event) => updateForm("monetization_ready_status", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 40 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Sponsorship Label" }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.sponsorship_label, onChange: (event) => updateForm("sponsorship_label", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 120 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Partner Label" }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.partner_label, onChange: (event) => updateForm("partner_label", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 120 }))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Brand Safe Status" }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.brand_safe_status, onChange: (event) => updateForm("brand_safe_status", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 40 })), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 self-end rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: form.analytics_enabled, onChange: (event) => updateForm("analytics_enabled", event.target.checked), label: "Analytics enabled" }))), /* @__PURE__ */ React.createElement(Field$2, { label: "Editorial Notes", help: "Internal editorial context for campaign planning, curation rationale, and staff handoff." }, /* @__PURE__ */ React.createElement( + ))), /* @__PURE__ */ React.createElement(AdvancedSection, { title: "Scheduling & Lifecycle", icon: "fa-calendar-days", defaultOpen: mode === "edit" }, /* @__PURE__ */ React.createElement(Field$3, { label: "Lifecycle State", help: "Draft keeps it hidden. Published makes it live. Archived retires it from active surfaces." }, /* @__PURE__ */ React.createElement(NovaSelect, { value: form.lifecycle_state, onChange: (val) => updateForm("lifecycle_state", val), searchable: false, options: [{ value: "draft", label: "Draft" }, { value: "scheduled", label: "Scheduled" }, { value: "published", label: "Published" }, { value: "featured", label: "Featured" }, { value: "archived", label: "Archived" }, { value: "expired", label: "Expired" }] })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$3, { label: "Publish At", help: "Leave empty to publish immediately. A future time keeps it off public surfaces until it goes live." }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: form.published_at, onChange: (nextValue) => updateForm("published_at", nextValue), placeholder: "Publish time", clearable: true, className: "bg-white/[0.04]" })), /* @__PURE__ */ React.createElement(Field$3, { label: "Unpublish At", help: "Optional automatic sunset time for seasonal or editorial collections." }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: form.unpublished_at, onChange: (nextValue) => updateForm("unpublished_at", nextValue), placeholder: "Unpublish time", clearable: true, className: "bg-white/[0.04]" }))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$3, { label: "Archive At", help: "Optional timestamp for moving the collection to long-term archive workflows." }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: form.archived_at, onChange: (nextValue) => updateForm("archived_at", nextValue), placeholder: "Archive time", clearable: true, className: "bg-white/[0.04]" })), /* @__PURE__ */ React.createElement(Field$3, { label: "Expire At", help: "Optional hard expiry for promotional or seasonal collections." }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: form.expired_at, onChange: (nextValue) => updateForm("expired_at", nextValue), placeholder: "Expiry time", clearable: true, className: "bg-white/[0.04]" })))), /* @__PURE__ */ React.createElement(AdvancedSection, { title: "Commercial & Administration", icon: "fa-briefcase", defaultOpen: mode === "edit" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement(Field$3, { label: "Promotion Tier" }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.promotion_tier, onChange: (event) => updateForm("promotion_tier", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 40 })), /* @__PURE__ */ React.createElement(Field$3, { label: "Monetization Status" }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.monetization_ready_status, onChange: (event) => updateForm("monetization_ready_status", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 40 })), /* @__PURE__ */ React.createElement(Field$3, { label: "Sponsorship Label" }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.sponsorship_label, onChange: (event) => updateForm("sponsorship_label", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 120 })), /* @__PURE__ */ React.createElement(Field$3, { label: "Partner Label" }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.partner_label, onChange: (event) => updateForm("partner_label", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 120 }))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$3, { label: "Brand Safe Status" }, /* @__PURE__ */ React.createElement("input", { type: "text", value: form.brand_safe_status, onChange: (event) => updateForm("brand_safe_status", event.target.value), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 40 })), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 self-end rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: form.analytics_enabled, onChange: (event) => updateForm("analytics_enabled", event.target.checked), label: "Analytics enabled" }))), /* @__PURE__ */ React.createElement(Field$3, { label: "Editorial Notes", help: "Internal editorial context for campaign planning, curation rationale, and staff handoff." }, /* @__PURE__ */ React.createElement( "textarea", { value: form.editorial_notes, @@ -73217,7 +76084,7 @@ function CollectionManage() { className: "min-h-[96px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]", maxLength: 2e3 } - )), canModerate ? /* @__PURE__ */ React.createElement(Field$2, { label: "Staff Commercial Notes", help: "Admin-only notes for sponsorship readiness, partner handling, and commercial review." }, /* @__PURE__ */ React.createElement( + )), canModerate ? /* @__PURE__ */ React.createElement(Field$3, { label: "Staff Commercial Notes", help: "Admin-only notes for sponsorship readiness, partner handling, and commercial review." }, /* @__PURE__ */ React.createElement( "textarea", { value: form.staff_commercial_notes, @@ -73618,7 +76485,7 @@ function CollectionManage() { onMoveUp: () => moveLayoutModule(index2, -1), onMoveDown: () => moveLayoutModule(index2, 1) } - ))))) : null, mode === "edit" && activeTab === "moderation" ? /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Moderation"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.03em] text-white" }, "Admin controls"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 max-w-3xl text-sm leading-relaxed text-slate-300" }, "Restrict public visibility, disable risky interactions, unfeature collections, or remove collaborators when a curation surface needs intervention.")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-5 xl:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.04] p-5" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Moderation Status" }, /* @__PURE__ */ React.createElement( + ))))) : null, mode === "edit" && activeTab === "moderation" ? /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Moderation"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold tracking-[-0.03em] text-white" }, "Admin controls"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 max-w-3xl text-sm leading-relaxed text-slate-300" }, "Restrict public visibility, disable risky interactions, unfeature collections, or remove collaborators when a curation surface needs intervention.")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-5 xl:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.04] p-5" }, /* @__PURE__ */ React.createElement(Field$3, { label: "Moderation Status" }, /* @__PURE__ */ React.createElement( NovaSelect, { value: collectionState?.moderation_status || "active", @@ -73633,7 +76500,7 @@ function CollectionManage() { } )), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement("span", null, "Allow comments"), /* @__PURE__ */ React.createElement(Checkbox, { checked: form.allow_comments, onChange: (event) => handleModerationToggle("allow_comments", event.target.checked) })), /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement("span", null, "Allow submissions"), /* @__PURE__ */ React.createElement(Checkbox, { checked: form.allow_submissions, onChange: (event) => handleModerationToggle("allow_submissions", event.target.checked) })), /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement("span", null, "Allow saves"), /* @__PURE__ */ React.createElement(Checkbox, { checked: form.allow_saves, onChange: (event) => handleModerationToggle("allow_saves", event.target.checked) })))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.04] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400" }, "Rapid actions"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: handleAdminUnfeature, className: "rounded-2xl border border-amber-300/25 bg-amber-300/10 px-4 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/15" }, "Remove featured placement"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => handleModerationStatusChange("under_review"), className: "rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, "Send to review"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => handleModerationStatusChange("restricted"), className: "rounded-2xl border border-rose-400/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15" }, "Restrict public access")), /* @__PURE__ */ React.createElement("div", { className: "mt-5 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-4 text-sm leading-relaxed text-slate-300" }, "Current state: ", /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, (collectionState?.moderation_status || "active").replace("_", " ")))))) : null))); } -const __vite_glob_0_34 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_44 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: CollectionManage }, Symbol.toStringTag, { value: "Module" })); @@ -73650,7 +76517,7 @@ function CollectionSeriesShow() { const stats = props.stats || {}; return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(SeoHead, { seo, title: seo.title || `${title} — Skinbase`, description: seo.description || description }), /* @__PURE__ */ React.createElement("div", { className: "relative min-h-screen overflow-hidden pb-16" }, /* @__PURE__ */ React.createElement("div", { "aria-hidden": "true", className: "pointer-events-none absolute inset-x-0 top-0 -z-10 h-[36rem] opacity-95", style: { background: "radial-gradient(circle at 10% 15%, rgba(59,130,246,0.18), transparent 28%), radial-gradient(circle at 84% 18%, rgba(34,197,94,0.16), transparent 24%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)" } }), /* @__PURE__ */ React.createElement("div", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 -z-10 opacity-[0.05]", style: { backgroundImage: "url(/gfx/noise.png)", backgroundSize: "180px" } }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-7xl px-4 pt-8 md:px-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("a", { href: "/collections/featured", className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-left fa-fw text-[11px]" }), "Back to collections"), leadCollection?.url ? /* @__PURE__ */ React.createElement("a", { href: leadCollection.url, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white" }, "Lead collection") : null), /* @__PURE__ */ React.createElement("section", { className: "mt-6 overflow-hidden rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-8 xl:grid-cols-[minmax(0,1.15fr)_400px] xl:items-end" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Series"), /* @__PURE__ */ React.createElement("h1", { className: "mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl" }, title), /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]" }, description), props.seriesKey ? /* @__PURE__ */ React.createElement("div", { className: "mt-5 inline-flex rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-300" }, props.seriesKey) : null), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 sm:grid-cols-3 xl:grid-cols-1" }, /* @__PURE__ */ React.createElement(StatCard$6, { icon: "fa-layer-group", label: "Collections", value: Number(stats.collections || collections.length).toLocaleString() }), /* @__PURE__ */ React.createElement(StatCard$6, { icon: "fa-user-group", label: "Creators", value: Number(stats.owners || 0).toLocaleString() }), /* @__PURE__ */ React.createElement(StatCard$6, { icon: "fa-images", label: "Artworks", value: Number(stats.artworks || 0).toLocaleString() })))), leadCollection ? /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-end justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Lead Entry"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Start with the opening collection")), stats.latest_activity_at ? /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.16em] text-slate-400" }, "Latest activity ", new Date(stats.latest_activity_at).toLocaleDateString()) : null), /* @__PURE__ */ React.createElement("div", { className: "mt-5 max-w-xl" }, /* @__PURE__ */ React.createElement(CollectionCard, { collection: leadCollection, isOwner: false }))) : null, /* @__PURE__ */ React.createElement("section", { className: "mt-8 pb-8" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-end justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Sequence"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Public collections in order"))), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3" }, collections.map((collection) => /* @__PURE__ */ React.createElement(CollectionCard, { key: collection.id, collection, isOwner: false }))))))); } -const __vite_glob_0_35 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_45 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: CollectionSeriesShow }, Symbol.toStringTag, { value: "Module" })); @@ -73723,7 +76590,7 @@ function normalizeContentTypeLabel(value) { } return raw.replace(/[-_]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); } -function getCsrfToken$9() { +function getCsrfToken$b() { if (typeof document === "undefined") return ""; return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } @@ -73748,7 +76615,7 @@ function sendDiscoveryEvent$1(endpoint, payload) { keepalive: true, headers: { "Content-Type": "application/json", - "X-CSRF-TOKEN": getCsrfToken$9(), + "X-CSRF-TOKEN": getCsrfToken$b(), "X-Requested-With": "XMLHttpRequest" }, body: JSON.stringify(payload) @@ -73764,7 +76631,7 @@ async function sendFeedbackSignal(endpoint, payload) { credentials: "same-origin", headers: { "Content-Type": "application/json", - "X-CSRF-TOKEN": getCsrfToken$9(), + "X-CSRF-TOKEN": getCsrfToken$b(), "X-Requested-With": "XMLHttpRequest" }, body: JSON.stringify(payload) @@ -73774,14 +76641,14 @@ async function sendFeedbackSignal(endpoint, payload) { } return response.json().catch(() => null); } -async function requestJson$j(endpoint, { method = "GET", body: body2 } = {}) { +async function requestJson$l(endpoint, { method = "GET", body: body2 } = {}) { const response = await fetch(endpoint, { method, credentials: "same-origin", headers: { Accept: "application/json", "Content-Type": "application/json", - "X-CSRF-TOKEN": getCsrfToken$9(), + "X-CSRF-TOKEN": getCsrfToken$b(), "X-Requested-With": "XMLHttpRequest" }, body: body2 ? JSON.stringify(body2) : void 0 @@ -74066,7 +76933,7 @@ function ArtworkCard$1({ method: "POST", headers: { "Content-Type": "application/json", - "X-CSRF-TOKEN": getCsrfToken$9() + "X-CSRF-TOKEN": getCsrfToken$b() }, credentials: "same-origin", body: JSON.stringify({ state: nextState }) @@ -74171,7 +77038,7 @@ function ArtworkCard$1({ } setCollectionOptionsLoading(true); try { - const payload = await requestJson$j(collectionOptionsEndpoint); + const payload = await requestJson$l(collectionOptionsEndpoint); setCollectionOptions(Array.isArray(payload?.data) ? payload.data : []); setCollectionCreateUrl(payload?.meta?.create_url || "/settings/collections/create"); setCollectionOptionsLoaded(true); @@ -74187,7 +77054,7 @@ function ArtworkCard$1({ setCollectionPickerError(""); setCollectionPickerNotice(""); try { - await requestJson$j(collection.attach_url, { + await requestJson$l(collection.attach_url, { method: "POST", body: { artwork_ids: [Number(item.id)] } }); @@ -74315,7 +77182,7 @@ function ArtworkCard$1({ } )); } -function getCsrfToken$8() { +function getCsrfToken$a() { if (typeof document === "undefined") return ""; return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } @@ -74353,7 +77220,7 @@ async function revokeDismissSignal(entry) { credentials: "same-origin", headers: { "Content-Type": "application/json", - "X-CSRF-TOKEN": getCsrfToken$8(), + "X-CSRF-TOKEN": getCsrfToken$a(), "X-Requested-With": "XMLHttpRequest" }, body: JSON.stringify(payload) @@ -74737,18 +77604,18 @@ function CommentList({ comments = [], canReply = false, onReply, onDelete, onRep } return /* @__PURE__ */ React.createElement("div", { className: "space-y-4" }, comments.map((comment) => /* @__PURE__ */ React.createElement(CommentItem$2, { key: comment.id, comment, canReply, onReply, onDelete, onReport }))); } -function getCsrfToken$7() { +function getCsrfToken$9() { if (typeof document === "undefined") return ""; return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } -async function requestJson$i(url, { method = "POST", body: body2 } = {}) { +async function requestJson$k(url, { method = "POST", body: body2 } = {}) { const response = await fetch(url, { method, credentials: "same-origin", headers: { Accept: "application/json", "Content-Type": "application/json", - "X-CSRF-TOKEN": getCsrfToken$7(), + "X-CSRF-TOKEN": getCsrfToken$9(), "X-Requested-With": "XMLHttpRequest" }, body: body2 ? JSON.stringify(body2) : void 0 @@ -75194,7 +78061,7 @@ function CollectionShow() { } setState((current) => ({ ...current, busy: true, notice: "" })); try { - const payload = await requestJson$i(state.liked ? engagement.unlike_url : engagement.like_url, { + const payload = await requestJson$k(state.liked ? engagement.unlike_url : engagement.like_url, { method: state.liked ? "DELETE" : "POST" }); setState((current) => ({ ...current, liked: Boolean(payload?.liked), busy: false })); @@ -75210,7 +78077,7 @@ function CollectionShow() { } setState((current) => ({ ...current, busy: true, notice: "" })); try { - const payload = await requestJson$i(state.following ? engagement.unfollow_url : engagement.follow_url, { + const payload = await requestJson$k(state.following ? engagement.unfollow_url : engagement.follow_url, { method: state.following ? "DELETE" : "POST" }); setState((current) => ({ ...current, following: Boolean(payload?.following), busy: false })); @@ -75221,7 +78088,7 @@ function CollectionShow() { } async function handleShare() { try { - const payload = await requestJson$i(engagement?.share_url, { method: "POST" }); + const payload = await requestJson$k(engagement?.share_url, { method: "POST" }); setCollection((current) => ({ ...current, shares_count: payload?.shares_count ?? current.shares_count })); await share({ title: collection?.title, @@ -75239,7 +78106,7 @@ function CollectionShow() { } setState((current) => ({ ...current, busy: true, notice: "" })); try { - const payload = await requestJson$i(state.saved ? engagement.unsave_url : engagement.save_url, { + const payload = await requestJson$k(state.saved ? engagement.unsave_url : engagement.save_url, { method: state.saved ? "DELETE" : "POST", body: state.saved ? void 0 : { context: "collection_detail", @@ -75256,7 +78123,7 @@ function CollectionShow() { } } async function handleCommentSubmit(body2) { - const payload = await requestJson$i(commentsEndpoint, { + const payload = await requestJson$k(commentsEndpoint, { method: "POST", body: { body: body2 } }); @@ -75264,14 +78131,14 @@ function CollectionShow() { setCollection((current) => ({ ...current, comments_count: payload?.comments_count ?? current.comments_count })); } async function handleDeleteComment(commentId) { - const payload = await requestJson$i(`${commentsEndpoint}/${commentId}`, { method: "DELETE" }); + const payload = await requestJson$k(`${commentsEndpoint}/${commentId}`, { method: "DELETE" }); setComments(payload?.comments || []); setCollection((current) => ({ ...current, comments_count: payload?.comments_count ?? current.comments_count })); } async function handleSubmitArtwork() { if (!submitEndpoint || !selectedArtworkId) return; try { - const payload = await requestJson$i(submitEndpoint, { + const payload = await requestJson$k(submitEndpoint, { method: "POST", body: { artwork_id: selectedArtworkId } }); @@ -75283,7 +78150,7 @@ function CollectionShow() { } async function handleSubmissionAction(submission, action) { const url = action === "approve" ? `/collections/submissions/${submission.id}/approve` : action === "reject" ? `/collections/submissions/${submission.id}/reject` : `/collections/submissions/${submission.id}`; - const payload = await requestJson$i(url, { + const payload = await requestJson$k(url, { method: action === "withdraw" ? "DELETE" : "POST" }); setSubmissions(payload?.submissions || []); @@ -75299,7 +78166,7 @@ function CollectionShow() { const reason = window.prompt("Why are you reporting this? (required)"); if (!reason || !reason.trim()) return; try { - await requestJson$i(reportEndpoint, { + await requestJson$k(reportEndpoint, { method: "POST", body: { target_type: targetType, @@ -75360,22 +78227,22 @@ function CollectionShow() { const renderedSidebarModules = enabledModules.filter((module) => module.slot === "sidebar").map(renderModule).filter(Boolean); return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(SeoHead, { seo, title: metaTitle, description: metaDescription, jsonLd: collectionSchema }), /* @__PURE__ */ React.createElement("div", { className: "relative min-h-screen overflow-hidden pb-16" }, /* @__PURE__ */ React.createElement("div", { "aria-hidden": "true", className: "pointer-events-none absolute inset-x-0 top-0 -z-10 h-[36rem] opacity-95", style: { background: "radial-gradient(circle at top left, rgba(56,189,248,0.18), transparent 32%), radial-gradient(circle at 82% 10%, rgba(249,115,22,0.18), transparent 26%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)" } }), /* @__PURE__ */ React.createElement("div", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 -z-10 opacity-[0.05]", style: { backgroundImage: "url(/gfx/noise.png)", backgroundSize: "180px" } }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-7xl px-4 pt-8 md:px-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("a", { href: profileCollectionsUrl, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-left fa-fw text-[11px]" }), "Back to collections"), isOwner && manageUrl ? /* @__PURE__ */ React.createElement("a", { href: manageUrl, className: "inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 font-semibold text-sky-100 transition hover:bg-sky-400/15" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-grip fa-fw text-[11px]" }), "Manage artworks") : null, isOwner && editUrl ? /* @__PURE__ */ React.createElement("a", { href: editUrl, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-pen-to-square fa-fw text-[11px]" }), "Edit details") : null, isOwner && analyticsUrl ? /* @__PURE__ */ React.createElement("a", { href: analyticsUrl, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-chart-column fa-fw text-[11px]" }), "Analytics") : null, isOwner && historyUrl ? /* @__PURE__ */ React.createElement("a", { href: historyUrl, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-timeline fa-fw text-[11px]" }), "History") : null), /* @__PURE__ */ React.createElement("section", { className: "mt-6 overflow-hidden rounded-[34px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] shadow-[0_30px_90px_rgba(2,6,23,0.32)] backdrop-blur-xl" }, /* @__PURE__ */ React.createElement("div", { className: `h-[3px] bg-gradient-to-r ${collection?.type === "editorial" ? "from-amber-400/80 via-amber-400/30 to-transparent" : collection?.type === "community" ? "from-emerald-400/80 via-emerald-400/30 to-transparent" : collection?.mode === "smart" ? "from-sky-400/80 via-sky-400/30 to-transparent" : "from-violet-400/80 via-violet-400/30 to-transparent"}` }), /* @__PURE__ */ React.createElement("div", { className: "grid items-start gap-6 p-5 md:p-7 xl:grid-cols-[minmax(0,1.2fr)_420px]" }, /* @__PURE__ */ React.createElement("div", { className: "relative self-start overflow-hidden rounded-[28px] border border-white/10 bg-slate-950/60" }, /* @__PURE__ */ React.createElement(CollectionCover, { collection }), /* @__PURE__ */ React.createElement("div", { className: "pointer-events-none absolute inset-0 bg-[linear-gradient(to_top,rgba(2,6,23,0.8),rgba(2,6,23,0.08))]" })), /* @__PURE__ */ React.createElement("div", { className: "relative overflow-hidden rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_28%),radial-gradient(circle_at_90%_8%,rgba(251,191,36,0.14),transparent_24%),linear-gradient(180deg,rgba(15,23,42,0.94),rgba(10,18,32,0.92))] px-5 py-6 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] md:px-6 md:py-7" }, /* @__PURE__ */ React.createElement("div", { "aria-hidden": "true", className: "pointer-events-none absolute -left-14 top-10 h-36 w-36 rounded-full bg-sky-400/10 blur-3xl" }), /* @__PURE__ */ React.createElement("div", { "aria-hidden": "true", className: "pointer-events-none absolute -right-10 bottom-8 h-32 w-32 rounded-full bg-amber-300/10 blur-3xl" }), /* @__PURE__ */ React.createElement("div", { className: "relative z-10 flex h-full flex-col justify-between" }, collection?.banner_text ? /* @__PURE__ */ React.createElement("div", { className: `mb-4 inline-flex max-w-full items-center gap-2 rounded-[22px] border px-4 py-3 text-sm font-medium shadow-[0_18px_40px_rgba(2,6,23,0.2)] ${spotlightClasses}` }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-sparkles text-[12px]" }), /* @__PURE__ */ React.createElement("span", { className: "truncate" }, collection.banner_text)) : null, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, collection?.is_featured ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full border border-amber-300/25 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, "Featured Collection") : null, collection?.mode === "smart" ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100" }, "Smart Collection") : null, /* @__PURE__ */ React.createElement(TypeBadge, { collection }), collection?.event_label ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white" }, collection.event_label) : null, collection?.campaign_label ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, collection.campaign_label) : null, collection?.badge_label ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white" }, collection.badge_label) : null, collection?.series_key ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white" }, "Series ", collection.series_order ? `#${collection.series_order}` : "") : null, isOwner ? /* @__PURE__ */ React.createElement(CollectionVisibilityBadge, { visibility: collection?.visibility }) : null), /* @__PURE__ */ React.createElement("h1", { className: "mt-4 max-w-3xl text-4xl font-black tracking-[-0.06em] text-white md:text-5xl xl:text-[4rem] xl:leading-[0.92]" }, collection?.title), showIntroBlock ? /* @__PURE__ */ React.createElement(React.Fragment, null, collection?.subtitle ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-lg text-slate-300 md:text-xl" }, collection.subtitle) : null, collection?.summary || collection?.description ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-2xl text-sm leading-relaxed text-slate-300 md:text-[15px]" }, collection?.summary || collection?.description) : /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-2xl text-sm leading-relaxed text-slate-400 md:text-[15px]" }, "A curated selection from @", owner?.username, ", assembled as a focused gallery rather than a simple archive."), collection?.smart_summary ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 max-w-2xl rounded-[22px] border border-sky-300/15 bg-sky-400/[0.07] px-4 py-3 text-sm leading-relaxed text-sky-100/90" }, collection.smart_summary) : null, featuringCreatorsCount > 1 ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm text-slate-300" }, "Featuring artworks by ", featuringCreatorsCount, " creators.") : null) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-7 space-y-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement(CollectionHeroAction, { onClick: handleLike, disabled: state.busy || !engagement?.like_url, icon: "fa-heart", label: state.liked ? "Liked" : "Like", tone: "rose", active: state.liked }), /* @__PURE__ */ React.createElement(CollectionHeroAction, { onClick: handleFollow, disabled: state.busy || !engagement?.follow_url, icon: "fa-bell", label: state.following ? "Following" : "Follow", tone: "emerald", active: state.following }), /* @__PURE__ */ React.createElement(CollectionHeroAction, { onClick: handleSave, disabled: state.busy || !engagement?.save_url && !engagement?.unsave_url, icon: "fa-bookmark", label: state.saved ? "Saved" : "Save", tone: "violet", active: state.saved })), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement(CollectionHeroAction, { onClick: handleShare, icon: "fa-share-nodes", label: "Share", tone: "neutral", compact: true }), featuredCollectionsUrl ? /* @__PURE__ */ React.createElement(CollectionHeroAction, { href: featuredCollectionsUrl, icon: "fa-compass", label: "Explore", tone: "sky", compact: true }) : null, reportEndpoint && !isOwner ? /* @__PURE__ */ React.createElement(CollectionHeroAction, { onClick: () => handleReport("collection", collection?.id), icon: "fa-flag", label: "Report", tone: "amber", compact: true }) : null)), state.notice ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm text-sky-100" }, state.notice) : null, /* @__PURE__ */ React.createElement(OwnerCard, { owner, collectionType: collection?.type }))))), heroMetrics.length || heroSignals.length ? /* @__PURE__ */ React.createElement("section", { className: "mt-6 rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-5 shadow-[0_20px_70px_rgba(2,6,23,0.22)] backdrop-blur-xl md:p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Collection Snapshot"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Stats and placement signals")), /* @__PURE__ */ React.createElement("p", { className: "max-w-xl text-sm leading-relaxed text-slate-400" }, "The engagement counters and ranking signals now live outside the hero so the header can stay focused on the artwork, title, and actions.")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 xl:grid-cols-[minmax(0,1.6fr)_minmax(320px,0.95fr)]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 sm:grid-cols-2 xl:grid-cols-5" }, heroMetrics.map((item) => /* @__PURE__ */ React.createElement(HeroMetricCard, { key: item.label, icon: item.icon, label: item.label, value: item.value, helper: item.helper, tone: item.tone }))), heroSignals.length ? /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 md:grid-cols-2 xl:grid-cols-1" }, heroSignals.map((item) => /* @__PURE__ */ React.createElement(HeroSignalCard, { key: item.label, icon: item.icon, label: item.label, value: item.value, description: item.description, tone: item.tone }))) : null)) : null, seriesContext?.url || seriesContext?.previous || seriesContext?.next || Array.isArray(seriesContext?.siblings) && seriesContext.siblings.length ? /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Series"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, seriesContext?.title || "Connected collection sequence"), seriesContext?.description ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 max-w-3xl text-sm leading-relaxed text-slate-300" }, seriesContext.description) : null), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-3" }, collection?.series_key ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300" }, collection.series_key) : null, seriesContext?.url ? /* @__PURE__ */ React.createElement("a", { href: seriesContext.url, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-list fa-fw text-[10px]" }), "View full series") : null)), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 lg:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4" }, seriesContext?.previous ? /* @__PURE__ */ React.createElement("a", { href: seriesContext.previous.url, className: "flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-4 transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400" }, "Previous"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, seriesContext.previous.title)), /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-left text-slate-500" })) : null, seriesContext?.next ? /* @__PURE__ */ React.createElement("a", { href: seriesContext.next.url, className: "flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-4 transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400" }, "Next"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, seriesContext.next.title)), /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-right text-slate-500" })) : null), Array.isArray(seriesContext?.siblings) && seriesContext.siblings.length ? /* @__PURE__ */ React.createElement("div", { className: "grid gap-4" }, seriesContext.siblings.slice(0, 2).map((item) => /* @__PURE__ */ React.createElement(CollectionCard, { key: item.id, collection: item, isOwner: false }))) : null)) : null, contextSignals.length ? /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-amber-300/15 bg-amber-400/10 text-amber-300" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-diagram-project text-sm" })), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80" }, "Related Context"), /* @__PURE__ */ React.createElement("h2", { className: "mt-1 text-2xl font-semibold text-white" }, "Campaign, event, and quality context"))), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300" }, contextSignals.length)), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3" }, contextSignals.map((item) => /* @__PURE__ */ React.createElement(ContextSignalCard, { key: `${item.meta}-${item.title}`, item })))) : null, storyLinks.length ? /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-lime-300/15 bg-lime-400/10 text-lime-300" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-book-open text-sm" })), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-lime-200/80" }, "Stories"), /* @__PURE__ */ React.createElement("h2", { className: "mt-1 text-2xl font-semibold text-white" }, "Stories and editorial references linked to this collection"))), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300" }, storyLinks.length)), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3" }, storyLinks.map((item) => /* @__PURE__ */ React.createElement(EntityLinkCard, { key: `${item.linked_type}-${item.linked_id}-${item.id}`, item })))) : null, taxonomyLinks.length ? /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-violet-300/15 bg-violet-400/10 text-violet-300" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-tags text-sm" })), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-violet-200/80" }, "Browse The Theme"), /* @__PURE__ */ React.createElement("h2", { className: "mt-1 text-2xl font-semibold text-white" }, "Categories and tags that anchor this collection"))), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300" }, taxonomyLinks.length)), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3" }, taxonomyLinks.map((item) => /* @__PURE__ */ React.createElement(EntityLinkCard, { key: `${item.linked_type}-${item.linked_id}-${item.id}`, item })))) : null, contributorLinks.length ? /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-sky-300/15 bg-sky-400/10 text-sky-300" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-user-group text-sm" })), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Connected Creators"), /* @__PURE__ */ React.createElement("h2", { className: "mt-1 text-2xl font-semibold text-white" }, "Creators and artworks that give the set its shape"))), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300" }, contributorLinks.length)), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3" }, contributorLinks.map((item) => /* @__PURE__ */ React.createElement(EntityLinkCard, { key: `${item.linked_type}-${item.linked_id}-${item.id}`, item })))) : null, renderedFullModules.length ? /* @__PURE__ */ React.createElement("div", { className: "mt-8 space-y-6" }, renderedFullModules) : null, renderedMainModules.length || renderedSidebarModules.length ? /* @__PURE__ */ React.createElement("section", { className: "mt-8 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, renderedMainModules), /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, renderedSidebarModules)) : null))); } -const __vite_glob_0_36 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_46 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: CollectionShow }, Symbol.toStringTag, { value: "Module" })); -function getCsrfToken$6() { +function getCsrfToken$8() { if (typeof document === "undefined") return ""; return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } -async function requestJson$h(url, { method = "POST", body: body2 } = {}) { +async function requestJson$j(url, { method = "POST", body: body2 } = {}) { const response = await fetch(url, { method, credentials: "same-origin", headers: { Accept: "application/json", "Content-Type": "application/json", - "X-CSRF-TOKEN": getCsrfToken$6(), + "X-CSRF-TOKEN": getCsrfToken$8(), "X-Requested-With": "XMLHttpRequest" }, body: body2 ? JSON.stringify(body2) : void 0 @@ -75396,7 +78263,7 @@ function isoToLocalInput$1(value) { function titleize(value) { return String(value || "").split("_").filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "); } -function Field$1({ label, help, children }) { +function Field$2({ label, help, children }) { return /* @__PURE__ */ React.createElement("label", { className: "block space-y-2" }, /* @__PURE__ */ React.createElement("span", { className: "text-sm font-semibold text-white" }, label), children, help ? /* @__PURE__ */ React.createElement("span", { className: "block text-xs leading-relaxed text-slate-400" }, help) : null); } function StatCard$5({ label, value, tone = "sky" }) { @@ -75580,7 +78447,7 @@ function CollectionStaffProgramming() { setNotice(""); try { const url = assignmentForm.id ? endpoints.updatePattern?.replace("__PROGRAM__", String(assignmentForm.id)) : endpoints.store; - const payload = await requestJson$h(url, { + const payload = await requestJson$j(url, { method: assignmentForm.id ? "PATCH" : "POST", body: { collection_id: Number(assignmentForm.collection_id), @@ -75607,7 +78474,7 @@ function CollectionStaffProgramming() { setBusy("preview"); setNotice(""); try { - const payload = await requestJson$h(endpoints.preview, { + const payload = await requestJson$j(endpoints.preview, { method: "POST", body: { program_key: previewForm.program_key, @@ -75635,7 +78502,7 @@ function CollectionStaffProgramming() { setBusy(kind); setNotice(""); try { - const payload = await requestJson$h(url, { + const payload = await requestJson$j(url, { method: "POST", body: { collection_id: selectedCollectionId ? Number(selectedCollectionId) : null @@ -75674,7 +78541,7 @@ function CollectionStaffProgramming() { } setQueueBusy((current) => ({ ...current, [item.id]: kind })); try { - const payload = await requestJson$h(url, { + const payload = await requestJson$j(url, { method: "POST", body: { source_collection_id: Number(sourceId), @@ -75703,7 +78570,7 @@ function CollectionStaffProgramming() { setBusy("hooks"); setNotice(""); try { - const payload = await requestJson$h(endpoints.metadataUpdate, { + const payload = await requestJson$j(endpoints.metadataUpdate, { method: "POST", body: { collection_id: Number(selectedCollectionId), @@ -75754,24 +78621,24 @@ function CollectionStaffProgramming() { const activeQueueAction = queueBusy[item.id] || ""; const cardBusy = Boolean(activeQueueAction); return /* @__PURE__ */ React.createElement("div", { key: `merge-pending-${item.id}`, className: `rounded-[24px] border border-white/10 bg-white/[0.04] p-4 transition ${cardBusy ? "ring-1 ring-sky-300/25" : ""}` }, /* @__PURE__ */ React.createElement("div", { className: "mb-4 flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, (item.comparison?.match_reasons || []).map((reason) => /* @__PURE__ */ React.createElement("span", { key: reason, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300" }, titleize(reason)))), cardBusy ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-sky-100" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-circle-notch fa-spin fa-fw text-[10px]" }), "Processing ", titleize(activeQueueAction)) : null), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 lg:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Source"), item.source ? /* @__PURE__ */ React.createElement(CollectionCard, { collection: item.source, isOwner: true }) : null), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Candidate"), item.target ? /* @__PURE__ */ React.createElement(CollectionCard, { collection: item.target, isOwner: true }) : null)), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-2 md:grid-cols-3 text-xs text-slate-400" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2" }, "Shared artworks: ", /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, item.comparison?.shared_artworks_count ?? 0)), /* @__PURE__ */ React.createElement("div", { className: "rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2" }, "Source count: ", /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, item.comparison?.source_artworks_count ?? 0)), /* @__PURE__ */ React.createElement("div", { className: "rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2" }, "Target count: ", /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, item.comparison?.target_artworks_count ?? 0))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, item.source?.manage_url ? /* @__PURE__ */ React.createElement("a", { href: item.source.manage_url, className: "inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-xs font-semibold text-rose-100 transition hover:bg-rose-400/15" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-code-compare fa-fw text-[10px]" }), "Review source") : null, item.target?.manage_url ? /* @__PURE__ */ React.createElement("a", { href: item.target.manage_url, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-up-right-from-square fa-fw text-[10px]" }), "Open target") : null, item.source?.id && historyPattern ? /* @__PURE__ */ React.createElement("a", { href: historyPattern.replace("__COLLECTION__", String(item.source.id)), className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-timeline fa-fw text-[10px]" }), "History") : null, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => handleQueueAction("canonicalize", item), disabled: cardBusy, className: "inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-xs font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${activeQueueAction === "canonicalize" ? "fa-circle-notch fa-spin" : "fa-badge-check"} fa-fw text-[10px]` }), activeQueueAction === "canonicalize" ? "Canonicalizing..." : "Canonicalize"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => handleQueueAction("merge", item), disabled: cardBusy, className: "inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-xs font-semibold text-emerald-100 transition hover:bg-emerald-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${activeQueueAction === "merge" ? "fa-circle-notch fa-spin" : "fa-code-merge"} fa-fw text-[10px]` }), activeQueueAction === "merge" ? "Merging..." : "Merge now"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => handleQueueAction("reject", item), disabled: cardBusy, className: "inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-xs font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${activeQueueAction === "reject" ? "fa-circle-notch fa-spin" : "fa-ban"} fa-fw text-[10px]` }), activeQueueAction === "reject" ? "Rejecting..." : "Reject"))); - }) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-5 py-10 text-sm text-slate-300" }, "No pending merge candidates right now."))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-slate-950/40 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80" }, "Recent Decisions"), /* @__PURE__ */ React.createElement("h3", { className: "mt-2 text-xl font-semibold text-white" }, "Canonical, reject, and merge history")), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300" }, mergeQueue?.recent?.length || 0)), /* @__PURE__ */ React.createElement("div", { className: "mt-5 space-y-4" }, (mergeQueue?.recent || []).length ? mergeQueue.recent.map((item) => /* @__PURE__ */ React.createElement("div", { key: `merge-recent-${item.id}`, className: "rounded-[24px] border border-white/10 bg-white/[0.04] p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("span", { className: "inline-flex rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, titleize(item.action_type)), item.summary ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-300" }, item.summary) : null, /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs text-slate-500" }, item.updated_at ? new Date(item.updated_at).toLocaleString() : "Unknown time", item.actor?.username ? ` • @${item.actor.username}` : ""))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 lg:grid-cols-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Source"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, item.source?.title || "Collection")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Target"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, item.target?.title || "Collection"))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, item.source?.manage_url ? /* @__PURE__ */ React.createElement("a", { href: item.source.manage_url, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-pen-to-square fa-fw text-[10px]" }), "Open source") : null, item.target?.manage_url ? /* @__PURE__ */ React.createElement("a", { href: item.target.manage_url, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-up-right-from-square fa-fw text-[10px]" }), "Open target") : null))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-5 py-10 text-sm text-slate-300" }, "No recent merge decisions yet."))))), /* @__PURE__ */ React.createElement("section", { className: "mt-8 grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]" }, /* @__PURE__ */ React.createElement("form", { onSubmit: handleAssignmentSubmit, className: "rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Assignment"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Program key and scope")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$1, { label: "Collection" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: String(assignmentForm.collection_id || ""), onChange: (val) => setAssignmentForm((current) => ({ ...current, collection_id: val })), options: collectionOptions.map((o) => ({ value: String(o.id), label: o.title })) })), /* @__PURE__ */ React.createElement(Field$1, { label: "Program Key", help: "Use stable internal names like discover-spring or homepage-hero." }, /* @__PURE__ */ React.createElement("input", { list: "program-key-options", value: assignmentForm.program_key, onChange: (event) => setAssignmentForm((current) => ({ ...current, program_key: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Placement Scope", help: "Optional placement scope such as homepage.hero or discover.rail." }, /* @__PURE__ */ React.createElement("input", { value: assignmentForm.placement_scope, onChange: (event) => setAssignmentForm((current) => ({ ...current, placement_scope: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Campaign Key" }, /* @__PURE__ */ React.createElement("input", { value: assignmentForm.campaign_key, onChange: (event) => setAssignmentForm((current) => ({ ...current, campaign_key: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Priority" }, /* @__PURE__ */ React.createElement("input", { type: "number", min: "-100", max: "100", value: assignmentForm.priority, onChange: (event) => setAssignmentForm((current) => ({ ...current, priority: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field$1, { label: "Starts At" }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: assignmentForm.starts_at, onChange: (nextValue) => setAssignmentForm((current) => ({ ...current, starts_at: nextValue })), placeholder: "Start time", clearable: true, className: "bg-white/[0.04]" })), /* @__PURE__ */ React.createElement(Field$1, { label: "Ends At" }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: assignmentForm.ends_at, onChange: (nextValue) => setAssignmentForm((current) => ({ ...current, ends_at: nextValue })), placeholder: "End time", clearable: true, className: "bg-white/[0.04]" }))), /* @__PURE__ */ React.createElement(Field$1, { label: "Notes", help: "Operational note for launch timing, overrides, or review context." }, /* @__PURE__ */ React.createElement("textarea", { value: assignmentForm.notes, onChange: (event) => setAssignmentForm((current) => ({ ...current, notes: event.target.value })), className: "mt-4 min-h-[120px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 1e3 })), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: busy === "assignment", className: "inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "assignment" ? "fa-circle-notch fa-spin" : "fa-sliders"} fa-fw` }), assignmentForm.id ? "Update Assignment" : "Save Assignment"), assignmentForm.id ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: resetAssignmentForm, className: "inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-rotate-left fa-fw" }), "Cancel Edit") : null), /* @__PURE__ */ React.createElement("datalist", { id: "program-key-options" }, programKeyOptions.map((option) => /* @__PURE__ */ React.createElement("option", { key: option, value: option })))), /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("form", { onSubmit: handlePreview, className: "rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80" }, "Preview"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Inspect a live program pool")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_140px_auto]" }, /* @__PURE__ */ React.createElement(Field$1, { label: "Program Key" }, /* @__PURE__ */ React.createElement("input", { list: "program-key-options", value: previewForm.program_key, onChange: (event) => setPreviewForm((current) => ({ ...current, program_key: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Limit" }, /* @__PURE__ */ React.createElement("input", { type: "number", min: "1", max: "24", value: previewForm.limit, onChange: (event) => setPreviewForm((current) => ({ ...current, limit: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("div", { className: "flex items-end" }, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: busy === "preview", className: "inline-flex h-[50px] w-full items-center justify-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "preview" ? "fa-circle-notch fa-spin" : "fa-binoculars"} fa-fw` }), "Preview"))), previewCollections.length ? /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 xl:grid-cols-2" }, previewCollections.map((collection) => /* @__PURE__ */ React.createElement("div", { key: collection.id, className: "rounded-[24px] border border-white/10 bg-slate-950/40 p-4" }, /* @__PURE__ */ React.createElement(CollectionCard, { collection, isOwner: true })))) : /* @__PURE__ */ React.createElement("div", { className: "mt-6 rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-5 py-8 text-sm text-slate-300" }, "Run a preview to inspect which collections currently qualify for a given program key.")), /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-lime-200/80" }, "Diagnostics"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Eligibility, duplicate risk, and ranking refresh")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 xl:grid-cols-[320px_minmax(0,1fr)]" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, "Operations summary"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-1" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-[0.16em] text-slate-400" }, "Stale health"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, Number(observabilitySummary?.counts?.stale_health || 0))), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-[0.16em] text-slate-400" }, "Stale recommendations"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, Number(observabilitySummary?.counts?.stale_recommendations || 0))), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-[0.16em] text-slate-400" }, "Placement blocked"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, Number(observabilitySummary?.counts?.placement_blocked || 0))), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-[0.16em] text-slate-400" }, "Duplicate risk"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, Number(observabilitySummary?.counts?.duplicate_risk || 0)))), observabilitySummary?.generated_at ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-xs text-slate-400" }, "Generated ", new Date(observabilitySummary.generated_at).toLocaleString()) : null), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, "Watchlist"), Array.isArray(observabilitySummary?.watchlist) && observabilitySummary.watchlist.length ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-4 xl:grid-cols-2" }, observabilitySummary.watchlist.map((collection) => /* @__PURE__ */ React.createElement("div", { key: `watch-${collection.id}`, className: "rounded-[20px] border border-white/10 bg-white/[0.04] p-3" }, /* @__PURE__ */ React.createElement(CollectionCard, { collection, isOwner: true })))) : /* @__PURE__ */ React.createElement("p", { className: "mt-3" }, "No watchlist items are currently flagged."))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]" }, /* @__PURE__ */ React.createElement(Field$1, { label: "Target Collection", help: "Leave a selection in place to inspect one collection. Change it any time before running a diagnostic." }, /* @__PURE__ */ React.createElement(NovaSelect, { value: String(selectedCollectionId || ""), onChange: (val) => setSelectedCollectionId(val), options: collectionOptions.map((o) => ({ value: String(o.id), label: o.title })) })), /* @__PURE__ */ React.createElement("div", { className: "flex items-end gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => runDiagnostic("eligibility"), disabled: busy !== "", className: "inline-flex items-center gap-2 rounded-2xl border border-lime-300/20 bg-lime-400/10 px-4 py-3 text-sm font-semibold text-lime-100 transition hover:bg-lime-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "eligibility" ? "fa-circle-notch fa-spin" : "fa-shield-check"} fa-fw` }), "Eligibility"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => runDiagnostic("duplicates"), disabled: busy !== "", className: "inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "duplicates" ? "fa-circle-notch fa-spin" : "fa-id-card"} fa-fw` }), "Duplicates"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => runDiagnostic("recommendations"), disabled: busy !== "", className: "inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "recommendations" ? "fa-circle-notch fa-spin" : "fa-arrows-rotate"} fa-fw` }), "Refresh"))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 lg:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, "Eligibility"), diagnostics.eligibility ? /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2" }, /* @__PURE__ */ React.createElement("p", null, diagnostics.eligibility.status === "queued" ? `${diagnostics.eligibility.count} collection(s) queued.` : `${diagnostics.eligibility.count} collection(s) evaluated.`), diagnostics.eligibility.message ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, diagnostics.eligibility.message) : null, (diagnostics.eligibility.items || []).map((item) => /* @__PURE__ */ React.createElement("div", { key: item.collection_id, className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, item.health_state || "unknown", " · ", item.readiness_state || "unknown", " · ", item.placement_eligibility ? "eligible" : "blocked"))) : /* @__PURE__ */ React.createElement("p", { className: "mt-3" }, "Run an eligibility refresh to verify readiness and public placement safety.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, "Duplicate candidates"), diagnostics.duplicates ? /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2" }, /* @__PURE__ */ React.createElement("p", null, diagnostics.duplicates.status === "queued" ? `${diagnostics.duplicates.count} collection(s) queued.` : `${diagnostics.duplicates.count} collection(s) with candidates.`), diagnostics.duplicates.message ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, diagnostics.duplicates.message) : null, (diagnostics.duplicates.items || []).map((item) => /* @__PURE__ */ React.createElement("div", { key: item.collection_id, className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, item.candidates?.length ? item.candidates.map((candidate) => candidate.title).join(", ") : "No candidates"))) : /* @__PURE__ */ React.createElement("p", { className: "mt-3" }, "Run duplicate scan to surface overlap before programming a collection widely.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, "Recommendation refresh"), diagnostics.recommendations ? /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2" }, /* @__PURE__ */ React.createElement("p", null, diagnostics.recommendations.status === "queued" ? `${diagnostics.recommendations.count} collection(s) queued.` : `${diagnostics.recommendations.count} collection(s) refreshed.`), diagnostics.recommendations.message ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, diagnostics.recommendations.message) : null, (diagnostics.recommendations.items || []).map((item) => /* @__PURE__ */ React.createElement("div", { key: item.collection_id, className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, titleize(item.recommendation_tier || "unknown"), " · ", titleize(item.ranking_bucket || "unknown"), " · ", titleize(item.search_boost_tier || "unknown")))) : /* @__PURE__ */ React.createElement("p", { className: "mt-3" }, "Run a recommendation refresh to update ranking and search tiers for this collection.")))), /* @__PURE__ */ React.createElement("form", { onSubmit: handleHooksSubmit, className: "rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-fuchsia-200/80" }, "Hooks"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Experiment and program governance"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-relaxed text-slate-300" }, "Control experiment keys, promotion tiers, and staff-only program governance hooks for the selected collection without leaving the programming studio.")), selectedCollection?.program_key && endpoints.publicProgramPattern ? /* @__PURE__ */ React.createElement("a", { href: buildProgramUrl(endpoints.publicProgramPattern, selectedCollection.program_key), className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-up-right-from-square fa-fw text-[11px]" }), "Open public program landing") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3" }, /* @__PURE__ */ React.createElement(Field$1, { label: "Experiment Key", help: "Internal test or treatment key for cross-surface collection experiments." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.experiment_key, onChange: (event) => setHooksForm((current) => ({ ...current, experiment_key: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Treatment", help: "Variant or treatment label tied to the experiment key." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.experiment_treatment, onChange: (event) => setHooksForm((current) => ({ ...current, experiment_treatment: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Placement Variant", help: "Surface-specific placement variant such as homepage_a or search_dense." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.placement_variant, onChange: (event) => setHooksForm((current) => ({ ...current, placement_variant: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Ranking Variant", help: "Override or annotate ranking mode experiments without changing the live pool logic." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.ranking_mode_variant, onChange: (event) => setHooksForm((current) => ({ ...current, ranking_mode_variant: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Pool Version", help: "Snapshot or rollout version for the collection pool definition." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.collection_pool_version, onChange: (event) => setHooksForm((current) => ({ ...current, collection_pool_version: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Test Label", help: "Human-readable campaign or experiment label for operations and diagnostics." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.test_label, onChange: (event) => setHooksForm((current) => ({ ...current, test_label: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 120 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Promotion Tier", help: "Optional internal tier for elevated or restrained programming treatment." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.promotion_tier, onChange: (event) => setHooksForm((current) => ({ ...current, promotion_tier: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 40 })), viewer.isAdmin ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Field$1, { label: "Partner Key", help: "Admin-only internal key for trusted partner or program ownership." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.partner_key, onChange: (event) => setHooksForm((current) => ({ ...current, partner_key: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Trust Tier", help: "Admin-only trust marker used for internal partner/program review logic." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.trust_tier, onChange: (event) => setHooksForm((current) => ({ ...current, trust_tier: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 40 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Sponsorship State", help: "Admin-only state for sponsored, pending, or cleared program treatment." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.sponsorship_state, onChange: (event) => setHooksForm((current) => ({ ...current, sponsorship_state: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 40 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Ownership Domain", help: "Admin-only internal ownership domain such as editorial, partner, creator_program, or events." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.ownership_domain, onChange: (event) => setHooksForm((current) => ({ ...current, ownership_domain: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Commercial Review", help: "Admin-only commercial review status for future partner and sponsor programs." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.commercial_review_state, onChange: (event) => setHooksForm((current) => ({ ...current, commercial_review_state: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 40 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Legal Review", help: "Admin-only legal review status when collections need compliance approval before wider promotion." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.legal_review_state, onChange: (event) => setHooksForm((current) => ({ ...current, legal_review_state: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 40 }))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-4 py-4 text-sm text-slate-300 md:col-span-2 xl:col-span-3" }, "Partner, sponsorship, ownership, and review metadata remain admin-only. Moderators can still manage experiment and promotion hooks here.")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex items-center gap-3 rounded-[20px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: hooksForm.placement_eligibility, onChange: (event) => setHooksForm((current) => ({ ...current, placement_eligibility: event.target.checked })), label: "Placement eligible override" })), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Experiment"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.experiment_key || selectedCollection?.experiment_key || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Treatment"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.experiment_treatment || selectedCollection?.experiment_treatment || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Placement Variant"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.placement_variant || selectedCollection?.placement_variant || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Workflow"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, titleize(hooksDiagnostics?.workflow_state || selectedCollection?.workflow_state || "unknown"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Health"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, titleize(hooksDiagnostics?.health_state || selectedCollection?.health_state || "unknown"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Recommendation Tier"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, titleize(hooksDiagnostics?.recommendation_tier || selectedCollection?.recommendation_tier || "unknown"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Ranking Bucket"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, titleize(hooksDiagnostics?.ranking_bucket || selectedCollection?.ranking_bucket || "unknown"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Ranking Variant"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.ranking_mode_variant || selectedCollection?.ranking_mode_variant || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Pool Version"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.collection_pool_version || selectedCollection?.collection_pool_version || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Test Label"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.test_label || selectedCollection?.test_label || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Promotion Tier"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.promotion_tier || selectedCollection?.promotion_tier || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Partner Key"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.partner_key || selectedCollection?.partner_key || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Trust Tier"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.trust_tier || selectedCollection?.trust_tier || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Sponsorship State"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.sponsorship_state || selectedCollection?.sponsorship_state || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Ownership Domain"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.ownership_domain || selectedCollection?.ownership_domain || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Commercial Review"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.commercial_review_state || selectedCollection?.commercial_review_state || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Legal Review"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.legal_review_state || selectedCollection?.legal_review_state || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Last Health Check"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.last_health_check_at ? new Date(hooksDiagnostics.last_health_check_at).toLocaleString() : "Not yet")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Last Recommendation Refresh"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.last_recommendation_refresh_at ? new Date(hooksDiagnostics.last_recommendation_refresh_at).toLocaleString() : "Not yet"))), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: busy === "hooks" || !selectedCollectionId, className: "inline-flex items-center gap-2 rounded-2xl border border-fuchsia-300/20 bg-fuchsia-400/10 px-5 py-3 text-sm font-semibold text-fuchsia-100 transition hover:bg-fuchsia-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "hooks" ? "fa-circle-notch fa-spin" : "fa-flask-vial"} fa-fw` }), "Save Hooks"), selectedCollection?.manage_url ? /* @__PURE__ */ React.createElement("a", { href: selectedCollection.manage_url, className: "inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-up-right-from-square fa-fw" }), "Open collection") : null)))), /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Assignments"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Current programming inventory")), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300" }, assignments.length)), /* @__PURE__ */ React.createElement("div", { className: "mt-6 space-y-5" }, assignments.length ? assignments.map((assignment) => /* @__PURE__ */ React.createElement("div", { key: assignment.id, className: "rounded-[28px] border border-white/10 bg-slate-950/40 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100" }, assignment.program_key), assignment.placement_scope ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, assignment.placement_scope) : null, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, "priority ", assignment.priority)), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => hydrateAssignment(assignment), className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-pen fa-fw text-[10px]" }), "Edit"), endpoints.managePattern ? /* @__PURE__ */ React.createElement("a", { href: endpoints.managePattern.replace("__COLLECTION__", String(assignment.collection?.id || "")), className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-up-right-from-square fa-fw text-[10px]" }), "Manage") : null)), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-5 xl:grid-cols-[minmax(0,1fr)_280px]" }, /* @__PURE__ */ React.createElement("div", null, assignment.collection ? /* @__PURE__ */ React.createElement(CollectionCard, { collection: assignment.collection, isOwner: true }) : null), /* @__PURE__ */ React.createElement("div", { className: "space-y-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3" }, "Campaign: ", assignment.campaign_key || "None"), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3" }, "Starts: ", assignment.starts_at ? new Date(assignment.starts_at).toLocaleString() : "Immediate"), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3" }, "Ends: ", assignment.ends_at ? new Date(assignment.ends_at).toLocaleString() : "Open-ended"), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3" }, "Placement: ", assignment.collection?.placement_eligibility ? "Eligible" : "Blocked"), assignment.notes ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3" }, assignment.notes) : null)))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300" }, "No programming assignments yet. Create the first one above.")))))); + }) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-5 py-10 text-sm text-slate-300" }, "No pending merge candidates right now."))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-slate-950/40 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80" }, "Recent Decisions"), /* @__PURE__ */ React.createElement("h3", { className: "mt-2 text-xl font-semibold text-white" }, "Canonical, reject, and merge history")), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300" }, mergeQueue?.recent?.length || 0)), /* @__PURE__ */ React.createElement("div", { className: "mt-5 space-y-4" }, (mergeQueue?.recent || []).length ? mergeQueue.recent.map((item) => /* @__PURE__ */ React.createElement("div", { key: `merge-recent-${item.id}`, className: "rounded-[24px] border border-white/10 bg-white/[0.04] p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("span", { className: "inline-flex rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, titleize(item.action_type)), item.summary ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-300" }, item.summary) : null, /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs text-slate-500" }, item.updated_at ? new Date(item.updated_at).toLocaleString() : "Unknown time", item.actor?.username ? ` • @${item.actor.username}` : ""))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 lg:grid-cols-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Source"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, item.source?.title || "Collection")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Target"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, item.target?.title || "Collection"))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, item.source?.manage_url ? /* @__PURE__ */ React.createElement("a", { href: item.source.manage_url, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-pen-to-square fa-fw text-[10px]" }), "Open source") : null, item.target?.manage_url ? /* @__PURE__ */ React.createElement("a", { href: item.target.manage_url, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-up-right-from-square fa-fw text-[10px]" }), "Open target") : null))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-5 py-10 text-sm text-slate-300" }, "No recent merge decisions yet."))))), /* @__PURE__ */ React.createElement("section", { className: "mt-8 grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]" }, /* @__PURE__ */ React.createElement("form", { onSubmit: handleAssignmentSubmit, className: "rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Assignment"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Program key and scope")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Collection" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: String(assignmentForm.collection_id || ""), onChange: (val) => setAssignmentForm((current) => ({ ...current, collection_id: val })), options: collectionOptions.map((o) => ({ value: String(o.id), label: o.title })) })), /* @__PURE__ */ React.createElement(Field$2, { label: "Program Key", help: "Use stable internal names like discover-spring or homepage-hero." }, /* @__PURE__ */ React.createElement("input", { list: "program-key-options", value: assignmentForm.program_key, onChange: (event) => setAssignmentForm((current) => ({ ...current, program_key: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Placement Scope", help: "Optional placement scope such as homepage.hero or discover.rail." }, /* @__PURE__ */ React.createElement("input", { value: assignmentForm.placement_scope, onChange: (event) => setAssignmentForm((current) => ({ ...current, placement_scope: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Campaign Key" }, /* @__PURE__ */ React.createElement("input", { value: assignmentForm.campaign_key, onChange: (event) => setAssignmentForm((current) => ({ ...current, campaign_key: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Priority" }, /* @__PURE__ */ React.createElement("input", { type: "number", min: "-100", max: "100", value: assignmentForm.priority, onChange: (event) => setAssignmentForm((current) => ({ ...current, priority: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field$2, { label: "Starts At" }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: assignmentForm.starts_at, onChange: (nextValue) => setAssignmentForm((current) => ({ ...current, starts_at: nextValue })), placeholder: "Start time", clearable: true, className: "bg-white/[0.04]" })), /* @__PURE__ */ React.createElement(Field$2, { label: "Ends At" }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: assignmentForm.ends_at, onChange: (nextValue) => setAssignmentForm((current) => ({ ...current, ends_at: nextValue })), placeholder: "End time", clearable: true, className: "bg-white/[0.04]" }))), /* @__PURE__ */ React.createElement(Field$2, { label: "Notes", help: "Operational note for launch timing, overrides, or review context." }, /* @__PURE__ */ React.createElement("textarea", { value: assignmentForm.notes, onChange: (event) => setAssignmentForm((current) => ({ ...current, notes: event.target.value })), className: "mt-4 min-h-[120px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 1e3 })), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: busy === "assignment", className: "inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "assignment" ? "fa-circle-notch fa-spin" : "fa-sliders"} fa-fw` }), assignmentForm.id ? "Update Assignment" : "Save Assignment"), assignmentForm.id ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: resetAssignmentForm, className: "inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-rotate-left fa-fw" }), "Cancel Edit") : null), /* @__PURE__ */ React.createElement("datalist", { id: "program-key-options" }, programKeyOptions.map((option) => /* @__PURE__ */ React.createElement("option", { key: option, value: option })))), /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("form", { onSubmit: handlePreview, className: "rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80" }, "Preview"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Inspect a live program pool")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_140px_auto]" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Program Key" }, /* @__PURE__ */ React.createElement("input", { list: "program-key-options", value: previewForm.program_key, onChange: (event) => setPreviewForm((current) => ({ ...current, program_key: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Limit" }, /* @__PURE__ */ React.createElement("input", { type: "number", min: "1", max: "24", value: previewForm.limit, onChange: (event) => setPreviewForm((current) => ({ ...current, limit: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("div", { className: "flex items-end" }, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: busy === "preview", className: "inline-flex h-[50px] w-full items-center justify-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "preview" ? "fa-circle-notch fa-spin" : "fa-binoculars"} fa-fw` }), "Preview"))), previewCollections.length ? /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 xl:grid-cols-2" }, previewCollections.map((collection) => /* @__PURE__ */ React.createElement("div", { key: collection.id, className: "rounded-[24px] border border-white/10 bg-slate-950/40 p-4" }, /* @__PURE__ */ React.createElement(CollectionCard, { collection, isOwner: true })))) : /* @__PURE__ */ React.createElement("div", { className: "mt-6 rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-5 py-8 text-sm text-slate-300" }, "Run a preview to inspect which collections currently qualify for a given program key.")), /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-lime-200/80" }, "Diagnostics"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Eligibility, duplicate risk, and ranking refresh")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 xl:grid-cols-[320px_minmax(0,1fr)]" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, "Operations summary"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-1" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-[0.16em] text-slate-400" }, "Stale health"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, Number(observabilitySummary?.counts?.stale_health || 0))), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-[0.16em] text-slate-400" }, "Stale recommendations"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, Number(observabilitySummary?.counts?.stale_recommendations || 0))), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-[0.16em] text-slate-400" }, "Placement blocked"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, Number(observabilitySummary?.counts?.placement_blocked || 0))), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-[0.16em] text-slate-400" }, "Duplicate risk"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-lg font-semibold text-white" }, Number(observabilitySummary?.counts?.duplicate_risk || 0)))), observabilitySummary?.generated_at ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-xs text-slate-400" }, "Generated ", new Date(observabilitySummary.generated_at).toLocaleString()) : null), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, "Watchlist"), Array.isArray(observabilitySummary?.watchlist) && observabilitySummary.watchlist.length ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-4 xl:grid-cols-2" }, observabilitySummary.watchlist.map((collection) => /* @__PURE__ */ React.createElement("div", { key: `watch-${collection.id}`, className: "rounded-[20px] border border-white/10 bg-white/[0.04] p-3" }, /* @__PURE__ */ React.createElement(CollectionCard, { collection, isOwner: true })))) : /* @__PURE__ */ React.createElement("p", { className: "mt-3" }, "No watchlist items are currently flagged."))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Target Collection", help: "Leave a selection in place to inspect one collection. Change it any time before running a diagnostic." }, /* @__PURE__ */ React.createElement(NovaSelect, { value: String(selectedCollectionId || ""), onChange: (val) => setSelectedCollectionId(val), options: collectionOptions.map((o) => ({ value: String(o.id), label: o.title })) })), /* @__PURE__ */ React.createElement("div", { className: "flex items-end gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => runDiagnostic("eligibility"), disabled: busy !== "", className: "inline-flex items-center gap-2 rounded-2xl border border-lime-300/20 bg-lime-400/10 px-4 py-3 text-sm font-semibold text-lime-100 transition hover:bg-lime-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "eligibility" ? "fa-circle-notch fa-spin" : "fa-shield-check"} fa-fw` }), "Eligibility"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => runDiagnostic("duplicates"), disabled: busy !== "", className: "inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "duplicates" ? "fa-circle-notch fa-spin" : "fa-id-card"} fa-fw` }), "Duplicates"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => runDiagnostic("recommendations"), disabled: busy !== "", className: "inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "recommendations" ? "fa-circle-notch fa-spin" : "fa-arrows-rotate"} fa-fw` }), "Refresh"))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 lg:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, "Eligibility"), diagnostics.eligibility ? /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2" }, /* @__PURE__ */ React.createElement("p", null, diagnostics.eligibility.status === "queued" ? `${diagnostics.eligibility.count} collection(s) queued.` : `${diagnostics.eligibility.count} collection(s) evaluated.`), diagnostics.eligibility.message ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, diagnostics.eligibility.message) : null, (diagnostics.eligibility.items || []).map((item) => /* @__PURE__ */ React.createElement("div", { key: item.collection_id, className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, item.health_state || "unknown", " · ", item.readiness_state || "unknown", " · ", item.placement_eligibility ? "eligible" : "blocked"))) : /* @__PURE__ */ React.createElement("p", { className: "mt-3" }, "Run an eligibility refresh to verify readiness and public placement safety.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, "Duplicate candidates"), diagnostics.duplicates ? /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2" }, /* @__PURE__ */ React.createElement("p", null, diagnostics.duplicates.status === "queued" ? `${diagnostics.duplicates.count} collection(s) queued.` : `${diagnostics.duplicates.count} collection(s) with candidates.`), diagnostics.duplicates.message ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, diagnostics.duplicates.message) : null, (diagnostics.duplicates.items || []).map((item) => /* @__PURE__ */ React.createElement("div", { key: item.collection_id, className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, item.candidates?.length ? item.candidates.map((candidate) => candidate.title).join(", ") : "No candidates"))) : /* @__PURE__ */ React.createElement("p", { className: "mt-3" }, "Run duplicate scan to surface overlap before programming a collection widely.")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, "Recommendation refresh"), diagnostics.recommendations ? /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2" }, /* @__PURE__ */ React.createElement("p", null, diagnostics.recommendations.status === "queued" ? `${diagnostics.recommendations.count} collection(s) queued.` : `${diagnostics.recommendations.count} collection(s) refreshed.`), diagnostics.recommendations.message ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, diagnostics.recommendations.message) : null, (diagnostics.recommendations.items || []).map((item) => /* @__PURE__ */ React.createElement("div", { key: item.collection_id, className: "rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2" }, titleize(item.recommendation_tier || "unknown"), " · ", titleize(item.ranking_bucket || "unknown"), " · ", titleize(item.search_boost_tier || "unknown")))) : /* @__PURE__ */ React.createElement("p", { className: "mt-3" }, "Run a recommendation refresh to update ranking and search tiers for this collection.")))), /* @__PURE__ */ React.createElement("form", { onSubmit: handleHooksSubmit, className: "rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-fuchsia-200/80" }, "Hooks"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Experiment and program governance"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-relaxed text-slate-300" }, "Control experiment keys, promotion tiers, and staff-only program governance hooks for the selected collection without leaving the programming studio.")), selectedCollection?.program_key && endpoints.publicProgramPattern ? /* @__PURE__ */ React.createElement("a", { href: buildProgramUrl(endpoints.publicProgramPattern, selectedCollection.program_key), className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-up-right-from-square fa-fw text-[11px]" }), "Open public program landing") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3" }, /* @__PURE__ */ React.createElement(Field$2, { label: "Experiment Key", help: "Internal test or treatment key for cross-surface collection experiments." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.experiment_key, onChange: (event) => setHooksForm((current) => ({ ...current, experiment_key: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Treatment", help: "Variant or treatment label tied to the experiment key." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.experiment_treatment, onChange: (event) => setHooksForm((current) => ({ ...current, experiment_treatment: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Placement Variant", help: "Surface-specific placement variant such as homepage_a or search_dense." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.placement_variant, onChange: (event) => setHooksForm((current) => ({ ...current, placement_variant: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Ranking Variant", help: "Override or annotate ranking mode experiments without changing the live pool logic." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.ranking_mode_variant, onChange: (event) => setHooksForm((current) => ({ ...current, ranking_mode_variant: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Pool Version", help: "Snapshot or rollout version for the collection pool definition." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.collection_pool_version, onChange: (event) => setHooksForm((current) => ({ ...current, collection_pool_version: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Test Label", help: "Human-readable campaign or experiment label for operations and diagnostics." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.test_label, onChange: (event) => setHooksForm((current) => ({ ...current, test_label: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 120 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Promotion Tier", help: "Optional internal tier for elevated or restrained programming treatment." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.promotion_tier, onChange: (event) => setHooksForm((current) => ({ ...current, promotion_tier: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 40 })), viewer.isAdmin ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Field$2, { label: "Partner Key", help: "Admin-only internal key for trusted partner or program ownership." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.partner_key, onChange: (event) => setHooksForm((current) => ({ ...current, partner_key: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Trust Tier", help: "Admin-only trust marker used for internal partner/program review logic." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.trust_tier, onChange: (event) => setHooksForm((current) => ({ ...current, trust_tier: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 40 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Sponsorship State", help: "Admin-only state for sponsored, pending, or cleared program treatment." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.sponsorship_state, onChange: (event) => setHooksForm((current) => ({ ...current, sponsorship_state: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 40 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Ownership Domain", help: "Admin-only internal ownership domain such as editorial, partner, creator_program, or events." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.ownership_domain, onChange: (event) => setHooksForm((current) => ({ ...current, ownership_domain: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Commercial Review", help: "Admin-only commercial review status for future partner and sponsor programs." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.commercial_review_state, onChange: (event) => setHooksForm((current) => ({ ...current, commercial_review_state: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 40 })), /* @__PURE__ */ React.createElement(Field$2, { label: "Legal Review", help: "Admin-only legal review status when collections need compliance approval before wider promotion." }, /* @__PURE__ */ React.createElement("input", { value: hooksForm.legal_review_state, onChange: (event) => setHooksForm((current) => ({ ...current, legal_review_state: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 40 }))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-4 py-4 text-sm text-slate-300 md:col-span-2 xl:col-span-3" }, "Partner, sponsorship, ownership, and review metadata remain admin-only. Moderators can still manage experiment and promotion hooks here.")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex items-center gap-3 rounded-[20px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: hooksForm.placement_eligibility, onChange: (event) => setHooksForm((current) => ({ ...current, placement_eligibility: event.target.checked })), label: "Placement eligible override" })), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Experiment"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.experiment_key || selectedCollection?.experiment_key || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Treatment"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.experiment_treatment || selectedCollection?.experiment_treatment || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Placement Variant"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.placement_variant || selectedCollection?.placement_variant || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Workflow"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, titleize(hooksDiagnostics?.workflow_state || selectedCollection?.workflow_state || "unknown"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Health"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, titleize(hooksDiagnostics?.health_state || selectedCollection?.health_state || "unknown"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Recommendation Tier"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, titleize(hooksDiagnostics?.recommendation_tier || selectedCollection?.recommendation_tier || "unknown"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Ranking Bucket"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, titleize(hooksDiagnostics?.ranking_bucket || selectedCollection?.ranking_bucket || "unknown"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Ranking Variant"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.ranking_mode_variant || selectedCollection?.ranking_mode_variant || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Pool Version"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.collection_pool_version || selectedCollection?.collection_pool_version || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Test Label"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.test_label || selectedCollection?.test_label || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Promotion Tier"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.promotion_tier || selectedCollection?.promotion_tier || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Partner Key"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.partner_key || selectedCollection?.partner_key || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Trust Tier"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.trust_tier || selectedCollection?.trust_tier || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Sponsorship State"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.sponsorship_state || selectedCollection?.sponsorship_state || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Ownership Domain"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.ownership_domain || selectedCollection?.ownership_domain || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Commercial Review"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.commercial_review_state || selectedCollection?.commercial_review_state || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Legal Review"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.legal_review_state || selectedCollection?.legal_review_state || "Not set")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Last Health Check"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.last_health_check_at ? new Date(hooksDiagnostics.last_health_check_at).toLocaleString() : "Not yet")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, "Last Recommendation Refresh"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, hooksDiagnostics?.last_recommendation_refresh_at ? new Date(hooksDiagnostics.last_recommendation_refresh_at).toLocaleString() : "Not yet"))), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: busy === "hooks" || !selectedCollectionId, className: "inline-flex items-center gap-2 rounded-2xl border border-fuchsia-300/20 bg-fuchsia-400/10 px-5 py-3 text-sm font-semibold text-fuchsia-100 transition hover:bg-fuchsia-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "hooks" ? "fa-circle-notch fa-spin" : "fa-flask-vial"} fa-fw` }), "Save Hooks"), selectedCollection?.manage_url ? /* @__PURE__ */ React.createElement("a", { href: selectedCollection.manage_url, className: "inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-up-right-from-square fa-fw" }), "Open collection") : null)))), /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Assignments"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Current programming inventory")), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300" }, assignments.length)), /* @__PURE__ */ React.createElement("div", { className: "mt-6 space-y-5" }, assignments.length ? assignments.map((assignment) => /* @__PURE__ */ React.createElement("div", { key: assignment.id, className: "rounded-[28px] border border-white/10 bg-slate-950/40 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100" }, assignment.program_key), assignment.placement_scope ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, assignment.placement_scope) : null, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, "priority ", assignment.priority)), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => hydrateAssignment(assignment), className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-pen fa-fw text-[10px]" }), "Edit"), endpoints.managePattern ? /* @__PURE__ */ React.createElement("a", { href: endpoints.managePattern.replace("__COLLECTION__", String(assignment.collection?.id || "")), className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-up-right-from-square fa-fw text-[10px]" }), "Manage") : null)), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-5 xl:grid-cols-[minmax(0,1fr)_280px]" }, /* @__PURE__ */ React.createElement("div", null, assignment.collection ? /* @__PURE__ */ React.createElement(CollectionCard, { collection: assignment.collection, isOwner: true }) : null), /* @__PURE__ */ React.createElement("div", { className: "space-y-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3" }, "Campaign: ", assignment.campaign_key || "None"), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3" }, "Starts: ", assignment.starts_at ? new Date(assignment.starts_at).toLocaleString() : "Immediate"), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3" }, "Ends: ", assignment.ends_at ? new Date(assignment.ends_at).toLocaleString() : "Open-ended"), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3" }, "Placement: ", assignment.collection?.placement_eligibility ? "Eligible" : "Blocked"), assignment.notes ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3" }, assignment.notes) : null)))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300" }, "No programming assignments yet. Create the first one above.")))))); } -const __vite_glob_0_37 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_47 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: CollectionStaffProgramming }, Symbol.toStringTag, { value: "Module" })); -function getCsrfToken$5() { +function getCsrfToken$7() { if (typeof document === "undefined") return ""; return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } -async function requestJson$g(url, { method = "POST", body: body2 } = {}) { +async function requestJson$i(url, { method = "POST", body: body2 } = {}) { const response = await fetch(url, { method, credentials: "same-origin", headers: { Accept: "application/json", "Content-Type": "application/json", - "X-CSRF-TOKEN": getCsrfToken$5(), + "X-CSRF-TOKEN": getCsrfToken$7(), "X-Requested-With": "XMLHttpRequest" }, body: body2 ? JSON.stringify(body2) : void 0 @@ -75797,7 +78664,7 @@ function rulesJsonToText(rulesJson) { return '{\n "campaign_key": "",\n "owner_username": "",\n "presentation_style": "hero_grid",\n "min_quality_score": 80\n}'; } } -function Field({ label, help, children }) { +function Field$1({ label, help, children }) { return /* @__PURE__ */ React.createElement("label", { className: "block space-y-2" }, /* @__PURE__ */ React.createElement("span", { className: "text-sm font-semibold text-white" }, label), children, help ? /* @__PURE__ */ React.createElement("span", { className: "block text-xs leading-relaxed text-slate-400" }, help) : null); } function CollectionStaffSurfaces() { @@ -75925,7 +78792,7 @@ function CollectionStaffSurfaces() { try { const rulesJson = definitionForm.rules_json.trim() ? JSON.parse(definitionForm.rules_json) : null; const url = definitionForm.id ? props.endpoints?.definitionsUpdatePattern?.replace("__DEFINITION__", String(definitionForm.id)) : props.endpoints?.definitionsStore; - const payload = await requestJson$g(url, { + const payload = await requestJson$i(url, { method: definitionForm.id ? "PATCH" : "POST", body: { ...definitionForm, @@ -75954,7 +78821,7 @@ function CollectionStaffSurfaces() { setNotice(""); try { const url = placementForm.id ? props.endpoints?.placementsUpdatePattern?.replace("__PLACEMENT__", String(placementForm.id)) : props.endpoints?.placementsStore; - const payload = await requestJson$g(url, { + const payload = await requestJson$i(url, { method: placementForm.id ? "PATCH" : "POST", body: { ...placementForm, @@ -75984,7 +78851,7 @@ function CollectionStaffSurfaces() { setBusy(`batch-${mode}`); setNotice(""); try { - const payload = await requestJson$g(props.endpoints?.batchEditorial, { + const payload = await requestJson$i(props.endpoints?.batchEditorial, { method: "POST", body: { ...batchForm, @@ -76046,7 +78913,7 @@ function CollectionStaffSurfaces() { setNotice(""); try { const url = props.endpoints?.definitionsDeletePattern?.replace("__DEFINITION__", String(definition2.id)); - await requestJson$g(url, { method: "DELETE" }); + await requestJson$i(url, { method: "DELETE" }); setDefinitions((current) => current.filter((item) => item.id !== definition2.id)); if (definitionForm.id === definition2.id) { resetDefinitionForm(); @@ -76064,7 +78931,7 @@ function CollectionStaffSurfaces() { setNotice(""); try { const url = props.endpoints?.placementsDeletePattern?.replace("__PLACEMENT__", String(placement.id)); - const payload = await requestJson$g(url, { method: "DELETE" }); + const payload = await requestJson$i(url, { method: "DELETE" }); setPlacements((current) => current.filter((item) => item.id !== placement.id)); setConflicts(Array.isArray(payload.conflicts) ? payload.conflicts : []); if (placementForm.id === placement.id) { @@ -76077,12 +78944,12 @@ function CollectionStaffSurfaces() { setBusy(""); } } - return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Se$1, null, /* @__PURE__ */ React.createElement("title", null, seo.title || "Collection Surfaces — Skinbase"), /* @__PURE__ */ React.createElement("meta", { name: "description", content: seo.description || "Staff tools for collection surfaces." }), seo.canonical ? /* @__PURE__ */ React.createElement("link", { rel: "canonical", href: seo.canonical }) : null, /* @__PURE__ */ React.createElement("meta", { name: "robots", content: seo.robots || "noindex,follow" })), /* @__PURE__ */ React.createElement("div", { className: "relative min-h-screen overflow-hidden pb-16" }, /* @__PURE__ */ React.createElement("div", { "aria-hidden": "true", className: "pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-95", style: { background: "radial-gradient(circle at 15% 14%, rgba(245,158,11,0.16), transparent 26%), radial-gradient(circle at 82% 18%, rgba(56,189,248,0.16), transparent 24%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)" } }), /* @__PURE__ */ React.createElement("div", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 -z-10 opacity-[0.05]", style: { backgroundImage: "url(/gfx/noise.png)", backgroundSize: "180px" } }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-7xl px-4 pt-8 md:px-6" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80" }, "Staff Surfaces"), /* @__PURE__ */ React.createElement("h1", { className: "mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl" }, "Collections placement studio"), /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]" }, "Define reusable discovery surfaces, then place eligible public collections into manual or campaign-specific slots with clear timing and notes."), notice ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm text-sky-100" }, notice) : null), /* @__PURE__ */ React.createElement("section", { className: "mt-8 grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]" }, /* @__PURE__ */ React.createElement("form", { onSubmit: handleDefinitionSubmit, className: "rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Surface Definition"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Rules and ranking")), definitionForm.id ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm text-slate-300" }, "Editing ", /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, definitionForm.surface_key)) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field, { label: "Surface Key", help: definitionForm.id ? "Surface keys stay stable during edits so existing placements remain attached." : null }, /* @__PURE__ */ React.createElement("input", { value: definitionForm.surface_key, onChange: (event) => setDefinitionForm((current) => ({ ...current, surface_key: event.target.value })), disabled: Boolean(definitionForm.id), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none disabled:cursor-not-allowed disabled:opacity-60", maxLength: 120 })), /* @__PURE__ */ React.createElement(Field, { label: "Title" }, /* @__PURE__ */ React.createElement("input", { value: definitionForm.title, onChange: (event) => setDefinitionForm((current) => ({ ...current, title: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 160 })), /* @__PURE__ */ React.createElement(Field, { label: "Mode" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: definitionForm.mode, onChange: (val) => setDefinitionForm((current) => ({ ...current, mode: val })), searchable: false, options: [{ value: "manual", label: "Manual" }, { value: "automatic", label: "Automatic" }, { value: "hybrid", label: "Hybrid" }] })), /* @__PURE__ */ React.createElement(Field, { label: "Ranking" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: definitionForm.ranking_mode, onChange: (val) => setDefinitionForm((current) => ({ ...current, ranking_mode: val })), searchable: false, options: [{ value: "ranking_score", label: "Ranking score" }, { value: "recent_activity", label: "Recent activity" }, { value: "quality_score", label: "Quality score" }] })), /* @__PURE__ */ React.createElement(Field, { label: "Max Items" }, /* @__PURE__ */ React.createElement("input", { type: "number", min: "1", max: "24", value: definitionForm.max_items, onChange: (event) => setDefinitionForm((current) => ({ ...current, max_items: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Starts At", help: "Optional activation window for the full surface definition." }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: definitionForm.starts_at, onChange: (nextValue) => setDefinitionForm((current) => ({ ...current, starts_at: nextValue })), placeholder: "Start time", clearable: true, className: "bg-white/[0.04]" })), /* @__PURE__ */ React.createElement(Field, { label: "Ends At", help: "Leave blank when the surface should stay live until staff changes it." }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: definitionForm.ends_at, onChange: (nextValue) => setDefinitionForm((current) => ({ ...current, ends_at: nextValue })), placeholder: "End time", clearable: true, className: "bg-white/[0.04]" })), /* @__PURE__ */ React.createElement(Field, { label: "Fallback Surface Key", help: "Optional fallback when this definition is inactive, scheduled out, or resolves no items." }, /* @__PURE__ */ React.createElement("input", { value: definitionForm.fallback_surface_key, onChange: (event) => setDefinitionForm((current) => ({ ...current, fallback_surface_key: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 120 })), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: definitionForm.is_active, onChange: (event) => setDefinitionForm((current) => ({ ...current, is_active: event.target.checked })), label: "Active" }))), /* @__PURE__ */ React.createElement(Field, { label: "Description", help: "Operational note for staff browsing this surface later." }, /* @__PURE__ */ React.createElement("textarea", { value: definitionForm.description, onChange: (event) => setDefinitionForm((current) => ({ ...current, description: event.target.value })), className: "mt-4 min-h-[96px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 400 })), /* @__PURE__ */ React.createElement(Field, { label: "Rules JSON", help: "Supported filters include campaign, event, season, type, presentation_style, theme_token, collaboration_mode, owner_username or owner_usernames, commercial_eligible_only, analytics_enabled_only, min_quality_score, min_ranking_score, include_collection_ids, exclude_collection_ids, and featured_only." }, /* @__PURE__ */ React.createElement("textarea", { value: definitionForm.rules_json, onChange: (event) => setDefinitionForm((current) => ({ ...current, rules_json: event.target.value })), className: "mt-4 min-h-[160px] w-full rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 font-mono text-sm text-white outline-none", spellCheck: false })), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap items-center gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: busy === "definition", className: "inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "definition" ? "fa-circle-notch fa-spin" : "fa-layer-group"} fa-fw` }), definitionForm.id ? "Update Definition" : "Save Definition"), definitionForm.id ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: resetDefinitionForm, className: "inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-rotate-left fa-fw" }), "Cancel Edit") : null)), /* @__PURE__ */ React.createElement("form", { onSubmit: handlePlacementSubmit, className: "rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80" }, "Surface Placement"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Manual and campaign slots")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field, { label: "Surface" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: placementForm.surface_key, onChange: (val) => setPlacementForm((current) => ({ ...current, surface_key: val })), options: surfaceKeyOptions.map((o) => ({ value: o, label: o })) })), /* @__PURE__ */ React.createElement(Field, { label: "Collection" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: String(placementForm.collection_id || ""), onChange: (val) => setPlacementForm((current) => ({ ...current, collection_id: val })), options: collectionOptions.map((o) => ({ value: String(o.id), label: o.title })) })), /* @__PURE__ */ React.createElement(Field, { label: "Placement Type" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: placementForm.placement_type, onChange: (val) => setPlacementForm((current) => ({ ...current, placement_type: val })), searchable: false, options: [{ value: "manual", label: "Manual" }, { value: "campaign", label: "Campaign" }, { value: "scheduled_override", label: "Scheduled override" }] })), /* @__PURE__ */ React.createElement(Field, { label: "Priority" }, /* @__PURE__ */ React.createElement("input", { type: "number", min: "-100", max: "100", value: placementForm.priority, onChange: (event) => setPlacementForm((current) => ({ ...current, priority: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Starts At" }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: placementForm.starts_at, onChange: (nextValue) => setPlacementForm((current) => ({ ...current, starts_at: nextValue })), placeholder: "Start time", clearable: true, className: "bg-white/[0.04]" })), /* @__PURE__ */ React.createElement(Field, { label: "Ends At" }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: placementForm.ends_at, onChange: (nextValue) => setPlacementForm((current) => ({ ...current, ends_at: nextValue })), placeholder: "End time", clearable: true, className: "bg-white/[0.04]" })), /* @__PURE__ */ React.createElement(Field, { label: "Campaign Key", help: "Optional campaign label for reporting and grouped overrides." }, /* @__PURE__ */ React.createElement("input", { value: placementForm.campaign_key, onChange: (event) => setPlacementForm((current) => ({ ...current, campaign_key: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: placementForm.is_active, onChange: (event) => setPlacementForm((current) => ({ ...current, is_active: event.target.checked })), label: "Active placement" }))), /* @__PURE__ */ React.createElement(Field, { label: "Notes", help: "Internal note for why this collection owns the slot." }, /* @__PURE__ */ React.createElement("textarea", { value: placementForm.notes, onChange: (event) => setPlacementForm((current) => ({ ...current, notes: event.target.value })), className: "mt-4 min-h-[110px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 1e3 })), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap items-center gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: busy === "placement", className: "inline-flex items-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "placement" ? "fa-circle-notch fa-spin" : "fa-thumbtack"} fa-fw` }), placementForm.id ? "Update Placement" : "Save Placement"), placementForm.id ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: resetPlacementForm, className: "inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-rotate-left fa-fw" }), "Cancel Edit") : null))), /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-lime-200/80" }, "Batch Editorial Tools"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Campaign planning in one pass")), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300" }, batchForm.collection_ids.length, " selected")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-white/10 bg-slate-950/40 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-white" }, "Choose collections"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-300" }, "The selector uses current public discovery candidates so staff can quickly prepare a seasonal or editorial run."), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2" }, collectionOptions.map((option) => { + return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Se$1, null, /* @__PURE__ */ React.createElement("title", null, seo.title || "Collection Surfaces — Skinbase"), /* @__PURE__ */ React.createElement("meta", { name: "description", content: seo.description || "Staff tools for collection surfaces." }), seo.canonical ? /* @__PURE__ */ React.createElement("link", { rel: "canonical", href: seo.canonical }) : null, /* @__PURE__ */ React.createElement("meta", { name: "robots", content: seo.robots || "noindex,follow" })), /* @__PURE__ */ React.createElement("div", { className: "relative min-h-screen overflow-hidden pb-16" }, /* @__PURE__ */ React.createElement("div", { "aria-hidden": "true", className: "pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-95", style: { background: "radial-gradient(circle at 15% 14%, rgba(245,158,11,0.16), transparent 26%), radial-gradient(circle at 82% 18%, rgba(56,189,248,0.16), transparent 24%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)" } }), /* @__PURE__ */ React.createElement("div", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 -z-10 opacity-[0.05]", style: { backgroundImage: "url(/gfx/noise.png)", backgroundSize: "180px" } }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-7xl px-4 pt-8 md:px-6" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80" }, "Staff Surfaces"), /* @__PURE__ */ React.createElement("h1", { className: "mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl" }, "Collections placement studio"), /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]" }, "Define reusable discovery surfaces, then place eligible public collections into manual or campaign-specific slots with clear timing and notes."), notice ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm text-sky-100" }, notice) : null), /* @__PURE__ */ React.createElement("section", { className: "mt-8 grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]" }, /* @__PURE__ */ React.createElement("form", { onSubmit: handleDefinitionSubmit, className: "rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Surface Definition"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Rules and ranking")), definitionForm.id ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm text-slate-300" }, "Editing ", /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, definitionForm.surface_key)) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$1, { label: "Surface Key", help: definitionForm.id ? "Surface keys stay stable during edits so existing placements remain attached." : null }, /* @__PURE__ */ React.createElement("input", { value: definitionForm.surface_key, onChange: (event) => setDefinitionForm((current) => ({ ...current, surface_key: event.target.value })), disabled: Boolean(definitionForm.id), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none disabled:cursor-not-allowed disabled:opacity-60", maxLength: 120 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Title" }, /* @__PURE__ */ React.createElement("input", { value: definitionForm.title, onChange: (event) => setDefinitionForm((current) => ({ ...current, title: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 160 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Mode" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: definitionForm.mode, onChange: (val) => setDefinitionForm((current) => ({ ...current, mode: val })), searchable: false, options: [{ value: "manual", label: "Manual" }, { value: "automatic", label: "Automatic" }, { value: "hybrid", label: "Hybrid" }] })), /* @__PURE__ */ React.createElement(Field$1, { label: "Ranking" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: definitionForm.ranking_mode, onChange: (val) => setDefinitionForm((current) => ({ ...current, ranking_mode: val })), searchable: false, options: [{ value: "ranking_score", label: "Ranking score" }, { value: "recent_activity", label: "Recent activity" }, { value: "quality_score", label: "Quality score" }] })), /* @__PURE__ */ React.createElement(Field$1, { label: "Max Items" }, /* @__PURE__ */ React.createElement("input", { type: "number", min: "1", max: "24", value: definitionForm.max_items, onChange: (event) => setDefinitionForm((current) => ({ ...current, max_items: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field$1, { label: "Starts At", help: "Optional activation window for the full surface definition." }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: definitionForm.starts_at, onChange: (nextValue) => setDefinitionForm((current) => ({ ...current, starts_at: nextValue })), placeholder: "Start time", clearable: true, className: "bg-white/[0.04]" })), /* @__PURE__ */ React.createElement(Field$1, { label: "Ends At", help: "Leave blank when the surface should stay live until staff changes it." }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: definitionForm.ends_at, onChange: (nextValue) => setDefinitionForm((current) => ({ ...current, ends_at: nextValue })), placeholder: "End time", clearable: true, className: "bg-white/[0.04]" })), /* @__PURE__ */ React.createElement(Field$1, { label: "Fallback Surface Key", help: "Optional fallback when this definition is inactive, scheduled out, or resolves no items." }, /* @__PURE__ */ React.createElement("input", { value: definitionForm.fallback_surface_key, onChange: (event) => setDefinitionForm((current) => ({ ...current, fallback_surface_key: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 120 })), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: definitionForm.is_active, onChange: (event) => setDefinitionForm((current) => ({ ...current, is_active: event.target.checked })), label: "Active" }))), /* @__PURE__ */ React.createElement(Field$1, { label: "Description", help: "Operational note for staff browsing this surface later." }, /* @__PURE__ */ React.createElement("textarea", { value: definitionForm.description, onChange: (event) => setDefinitionForm((current) => ({ ...current, description: event.target.value })), className: "mt-4 min-h-[96px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 400 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Rules JSON", help: "Supported filters include campaign, event, season, type, presentation_style, theme_token, collaboration_mode, owner_username or owner_usernames, commercial_eligible_only, analytics_enabled_only, min_quality_score, min_ranking_score, include_collection_ids, exclude_collection_ids, and featured_only." }, /* @__PURE__ */ React.createElement("textarea", { value: definitionForm.rules_json, onChange: (event) => setDefinitionForm((current) => ({ ...current, rules_json: event.target.value })), className: "mt-4 min-h-[160px] w-full rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 font-mono text-sm text-white outline-none", spellCheck: false })), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap items-center gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: busy === "definition", className: "inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "definition" ? "fa-circle-notch fa-spin" : "fa-layer-group"} fa-fw` }), definitionForm.id ? "Update Definition" : "Save Definition"), definitionForm.id ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: resetDefinitionForm, className: "inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-rotate-left fa-fw" }), "Cancel Edit") : null)), /* @__PURE__ */ React.createElement("form", { onSubmit: handlePlacementSubmit, className: "rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80" }, "Surface Placement"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Manual and campaign slots")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$1, { label: "Surface" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: placementForm.surface_key, onChange: (val) => setPlacementForm((current) => ({ ...current, surface_key: val })), options: surfaceKeyOptions.map((o) => ({ value: o, label: o })) })), /* @__PURE__ */ React.createElement(Field$1, { label: "Collection" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: String(placementForm.collection_id || ""), onChange: (val) => setPlacementForm((current) => ({ ...current, collection_id: val })), options: collectionOptions.map((o) => ({ value: String(o.id), label: o.title })) })), /* @__PURE__ */ React.createElement(Field$1, { label: "Placement Type" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: placementForm.placement_type, onChange: (val) => setPlacementForm((current) => ({ ...current, placement_type: val })), searchable: false, options: [{ value: "manual", label: "Manual" }, { value: "campaign", label: "Campaign" }, { value: "scheduled_override", label: "Scheduled override" }] })), /* @__PURE__ */ React.createElement(Field$1, { label: "Priority" }, /* @__PURE__ */ React.createElement("input", { type: "number", min: "-100", max: "100", value: placementForm.priority, onChange: (event) => setPlacementForm((current) => ({ ...current, priority: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field$1, { label: "Starts At" }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: placementForm.starts_at, onChange: (nextValue) => setPlacementForm((current) => ({ ...current, starts_at: nextValue })), placeholder: "Start time", clearable: true, className: "bg-white/[0.04]" })), /* @__PURE__ */ React.createElement(Field$1, { label: "Ends At" }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: placementForm.ends_at, onChange: (nextValue) => setPlacementForm((current) => ({ ...current, ends_at: nextValue })), placeholder: "End time", clearable: true, className: "bg-white/[0.04]" })), /* @__PURE__ */ React.createElement(Field$1, { label: "Campaign Key", help: "Optional campaign label for reporting and grouped overrides." }, /* @__PURE__ */ React.createElement("input", { value: placementForm.campaign_key, onChange: (event) => setPlacementForm((current) => ({ ...current, campaign_key: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: placementForm.is_active, onChange: (event) => setPlacementForm((current) => ({ ...current, is_active: event.target.checked })), label: "Active placement" }))), /* @__PURE__ */ React.createElement(Field$1, { label: "Notes", help: "Internal note for why this collection owns the slot." }, /* @__PURE__ */ React.createElement("textarea", { value: placementForm.notes, onChange: (event) => setPlacementForm((current) => ({ ...current, notes: event.target.value })), className: "mt-4 min-h-[110px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 1e3 })), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap items-center gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: busy === "placement", className: "inline-flex items-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "placement" ? "fa-circle-notch fa-spin" : "fa-thumbtack"} fa-fw` }), placementForm.id ? "Update Placement" : "Save Placement"), placementForm.id ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: resetPlacementForm, className: "inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-rotate-left fa-fw" }), "Cancel Edit") : null))), /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-lime-200/80" }, "Batch Editorial Tools"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Campaign planning in one pass")), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300" }, batchForm.collection_ids.length, " selected")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-white/10 bg-slate-950/40 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-white" }, "Choose collections"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-300" }, "The selector uses current public discovery candidates so staff can quickly prepare a seasonal or editorial run."), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2" }, collectionOptions.map((option) => { const checked = batchForm.collection_ids.includes(option.id); return /* @__PURE__ */ React.createElement("label", { key: option.id, className: `flex cursor-pointer items-start gap-3 rounded-[22px] border px-4 py-3 transition ${checked ? "border-lime-300/30 bg-lime-400/10" : "border-white/10 bg-white/[0.04] hover:bg-white/[0.07]"}` }, /* @__PURE__ */ React.createElement(Checkbox, { checked, onChange: () => toggleBatchCollection(option.id) }), /* @__PURE__ */ React.createElement("span", { className: "min-w-0" }, /* @__PURE__ */ React.createElement("span", { className: "block truncate text-sm font-semibold text-white" }, option.title), /* @__PURE__ */ React.createElement("span", { className: "mt-1 block text-xs text-slate-400" }, option.type || "collection", " · ", option.visibility || "public"))); - }))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-white/10 bg-slate-950/40 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-white" }, "Campaign metadata"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field, { label: "Campaign Key" }, /* @__PURE__ */ React.createElement("input", { value: batchForm.campaign_key, onChange: (event) => setBatchForm((current) => ({ ...current, campaign_key: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field, { label: "Campaign Label" }, /* @__PURE__ */ React.createElement("input", { value: batchForm.campaign_label, onChange: (event) => setBatchForm((current) => ({ ...current, campaign_label: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 120 })), /* @__PURE__ */ React.createElement(Field, { label: "Event Label" }, /* @__PURE__ */ React.createElement("input", { value: batchForm.event_label, onChange: (event) => setBatchForm((current) => ({ ...current, event_label: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 120 })), /* @__PURE__ */ React.createElement(Field, { label: "Season Key" }, /* @__PURE__ */ React.createElement("input", { value: batchForm.season_key, onChange: (event) => setBatchForm((current) => ({ ...current, season_key: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 }))), /* @__PURE__ */ React.createElement(Field, { label: "Editorial Notes", help: "Shared context recorded on each selected collection." }, /* @__PURE__ */ React.createElement("textarea", { value: batchForm.editorial_notes, onChange: (event) => setBatchForm((current) => ({ ...current, editorial_notes: event.target.value })), className: "mt-4 min-h-[120px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 4e3 })))), /* @__PURE__ */ React.createElement("div", { className: "space-y-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-white/10 bg-slate-950/40 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-white" }, "Optional placement plan"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-300" }, "If you set a surface, the preview shows which collections can safely be placed and which ones will be skipped."), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field, { label: "Surface" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: batchForm.surface_key, onChange: (val) => setBatchForm((current) => ({ ...current, surface_key: val })), placeholder: "No placement", options: surfaceKeyOptions.map((o) => ({ value: o, label: o })) })), /* @__PURE__ */ React.createElement(Field, { label: "Placement Type" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: batchForm.placement_type, onChange: (val) => setBatchForm((current) => ({ ...current, placement_type: val })), searchable: false, options: [{ value: "campaign", label: "Campaign" }, { value: "manual", label: "Manual" }, { value: "scheduled_override", label: "Scheduled override" }] })), /* @__PURE__ */ React.createElement(Field, { label: "Priority" }, /* @__PURE__ */ React.createElement("input", { type: "number", min: "-100", max: "100", value: batchForm.priority, onChange: (event) => setBatchForm((current) => ({ ...current, priority: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: batchForm.is_active, onChange: (event) => setBatchForm((current) => ({ ...current, is_active: event.target.checked })), label: "Active placement" })), /* @__PURE__ */ React.createElement(Field, { label: "Starts At" }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: batchForm.starts_at, onChange: (nextValue) => setBatchForm((current) => ({ ...current, starts_at: nextValue })), placeholder: "Start time", clearable: true, className: "bg-white/[0.04]" })), /* @__PURE__ */ React.createElement(Field, { label: "Ends At" }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: batchForm.ends_at, onChange: (nextValue) => setBatchForm((current) => ({ ...current, ends_at: nextValue })), placeholder: "End time", clearable: true, className: "bg-white/[0.04]" }))), /* @__PURE__ */ React.createElement(Field, { label: "Placement Notes" }, /* @__PURE__ */ React.createElement("textarea", { value: batchForm.notes, onChange: (event) => setBatchForm((current) => ({ ...current, notes: event.target.value })), className: "mt-4 min-h-[110px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 1e3 })), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => handleBatchEditorial("preview"), disabled: busy === "batch-preview", className: "inline-flex items-center gap-2 rounded-2xl border border-lime-300/20 bg-lime-400/10 px-5 py-3 text-sm font-semibold text-lime-100 transition hover:bg-lime-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "batch-preview" ? "fa-circle-notch fa-spin" : "fa-flask"} fa-fw` }), "Preview Batch"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => handleBatchEditorial("apply"), disabled: busy === "batch-apply", className: "inline-flex items-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "batch-apply" ? "fa-circle-notch fa-spin" : "fa-wand-magic-sparkles"} fa-fw` }), "Apply Batch"))), batchResult ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-white/10 bg-slate-950/40 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-white" }, "Preview results"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-300" }, batchResult.collections_count, " collections reviewed, ", batchResult.placement_eligible_count, " placement-ready."))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, (batchResult.items || []).map((item) => /* @__PURE__ */ React.createElement("div", { key: item.collection?.id, className: "rounded-[22px] border border-white/10 bg-white/[0.04] p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-white" }, item.collection?.title), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-xs text-slate-400" }, item.collection?.visibility, " · ", item.collection?.lifecycle_state, " · ", item.collection?.moderation_status)), item.placement ? /* @__PURE__ */ React.createElement("span", { className: `rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${item.placement.eligible ? "border-lime-300/20 bg-lime-400/10 text-lime-100" : "border-rose-300/20 bg-rose-400/10 text-rose-100"}` }, item.placement.eligible ? `ready for ${item.placement.surface_key}` : "placement skipped") : /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, "metadata only")), item.eligibility?.reasons?.length ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-xs text-amber-100/80" }, "Campaign readiness: ", item.eligibility.reasons.join(" ")) : null, item.placement?.reasons?.length ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs text-rose-100/80" }, "Placement: ", item.placement.reasons.join(" ")) : null)))) : null))), /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Definitions"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Registered surfaces")), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300" }, definitions.length)), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 lg:grid-cols-2" }, definitions.map((definition2) => /* @__PURE__ */ React.createElement("div", { key: definition2.id, className: "rounded-[24px] border border-white/10 bg-slate-950/40 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100" }, definition2.surface_key), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, definition2.mode)), /* @__PURE__ */ React.createElement("h3", { className: "mt-4 text-lg font-semibold text-white" }, definition2.title), definition2.description ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-300" }, definition2.description) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2 text-xs text-slate-400" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1" }, definition2.ranking_mode), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1" }, "max ", definition2.max_items), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1" }, definition2.is_active ? "active" : "inactive"), definition2.starts_at ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1" }, "starts ", new Date(definition2.starts_at).toLocaleString()) : null, definition2.ends_at ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1" }, "ends ", new Date(definition2.ends_at).toLocaleString()) : null, definition2.fallback_surface_key ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1" }, "fallback ", definition2.fallback_surface_key) : null), /* @__PURE__ */ React.createElement("div", { className: "mt-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => hydrateDefinition(definition2), className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-pen fa-fw text-[10px]" }), "Edit Definition"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => handleDeleteDefinition(definition2), disabled: busy === `delete-definition-${definition2.id}`, className: "inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-xs font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === `delete-definition-${definition2.id}` ? "fa-circle-notch fa-spin" : "fa-trash"} fa-fw text-[10px]` }), "Delete"))))))), conflicts.length ? /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[32px] border border-rose-300/20 bg-rose-500/10 p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-rose-100/80" }, "Conflicts"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Schedule overlaps need review")), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-xs font-semibold text-rose-100" }, conflicts.length)), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 lg:grid-cols-2" }, conflicts.map((conflict, index2) => /* @__PURE__ */ React.createElement("div", { key: `${conflict.surface_key}-${index2}`, className: "rounded-[24px] border border-rose-300/20 bg-slate-950/40 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-100" }, conflict.surface_key)), /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm text-rose-50" }, conflict.summary), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-xs text-rose-100/70" }, "Window: ", conflict.window?.starts_at ? new Date(conflict.window.starts_at).toLocaleString() : "Immediate", " to ", conflict.window?.ends_at ? new Date(conflict.window.ends_at).toLocaleString() : "Open-ended"))))) : null, /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80" }, "Placements"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Active and scheduled slots")), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300" }, placements.length)), /* @__PURE__ */ React.createElement("div", { className: "mt-6 space-y-5" }, placements.map((placement) => /* @__PURE__ */ React.createElement("div", { key: placement.id, className: "rounded-[28px] border border-white/10 bg-slate-950/40 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, placement.surface_key), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, placement.placement_type), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, "priority ", placement.priority), conflictPlacementIds.has(placement.id) || placement.has_conflict ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-100" }, "conflict") : null), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => hydratePlacement(placement), className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-pen fa-fw text-[10px]" }), "Edit"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => handleDeletePlacement(placement), disabled: busy === `delete-placement-${placement.id}`, className: "inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-xs font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === `delete-placement-${placement.id}` ? "fa-circle-notch fa-spin" : "fa-trash"} fa-fw text-[10px]` }), "Delete"))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-5 xl:grid-cols-[minmax(0,1fr)_280px]" }, /* @__PURE__ */ React.createElement("div", null, placement.collection ? /* @__PURE__ */ React.createElement(CollectionCard, { collection: placement.collection, isOwner: true }) : null), /* @__PURE__ */ React.createElement("div", { className: "space-y-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3" }, "Starts: ", placement.starts_at ? new Date(placement.starts_at).toLocaleString() : "Immediate"), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3" }, "Ends: ", placement.ends_at ? new Date(placement.ends_at).toLocaleString() : "Open-ended"), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3" }, "Campaign: ", placement.campaign_key || "None"), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3" }, "Status: ", placement.is_active ? "Active" : "Inactive"), placement.notes ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3 text-slate-300" }, placement.notes) : null))))))))); + }))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-white/10 bg-slate-950/40 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-white" }, "Campaign metadata"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$1, { label: "Campaign Key" }, /* @__PURE__ */ React.createElement("input", { value: batchForm.campaign_key, onChange: (event) => setBatchForm((current) => ({ ...current, campaign_key: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Campaign Label" }, /* @__PURE__ */ React.createElement("input", { value: batchForm.campaign_label, onChange: (event) => setBatchForm((current) => ({ ...current, campaign_label: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 120 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Event Label" }, /* @__PURE__ */ React.createElement("input", { value: batchForm.event_label, onChange: (event) => setBatchForm((current) => ({ ...current, event_label: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 120 })), /* @__PURE__ */ React.createElement(Field$1, { label: "Season Key" }, /* @__PURE__ */ React.createElement("input", { value: batchForm.season_key, onChange: (event) => setBatchForm((current) => ({ ...current, season_key: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 80 }))), /* @__PURE__ */ React.createElement(Field$1, { label: "Editorial Notes", help: "Shared context recorded on each selected collection." }, /* @__PURE__ */ React.createElement("textarea", { value: batchForm.editorial_notes, onChange: (event) => setBatchForm((current) => ({ ...current, editorial_notes: event.target.value })), className: "mt-4 min-h-[120px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 4e3 })))), /* @__PURE__ */ React.createElement("div", { className: "space-y-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-white/10 bg-slate-950/40 p-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-white" }, "Optional placement plan"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-300" }, "If you set a surface, the preview shows which collections can safely be placed and which ones will be skipped."), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field$1, { label: "Surface" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: batchForm.surface_key, onChange: (val) => setBatchForm((current) => ({ ...current, surface_key: val })), placeholder: "No placement", options: surfaceKeyOptions.map((o) => ({ value: o, label: o })) })), /* @__PURE__ */ React.createElement(Field$1, { label: "Placement Type" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: batchForm.placement_type, onChange: (val) => setBatchForm((current) => ({ ...current, placement_type: val })), searchable: false, options: [{ value: "campaign", label: "Campaign" }, { value: "manual", label: "Manual" }, { value: "scheduled_override", label: "Scheduled override" }] })), /* @__PURE__ */ React.createElement(Field$1, { label: "Priority" }, /* @__PURE__ */ React.createElement("input", { type: "number", min: "-100", max: "100", value: batchForm.priority, onChange: (event) => setBatchForm((current) => ({ ...current, priority: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: batchForm.is_active, onChange: (event) => setBatchForm((current) => ({ ...current, is_active: event.target.checked })), label: "Active placement" })), /* @__PURE__ */ React.createElement(Field$1, { label: "Starts At" }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: batchForm.starts_at, onChange: (nextValue) => setBatchForm((current) => ({ ...current, starts_at: nextValue })), placeholder: "Start time", clearable: true, className: "bg-white/[0.04]" })), /* @__PURE__ */ React.createElement(Field$1, { label: "Ends At" }, /* @__PURE__ */ React.createElement(DateTimePicker, { value: batchForm.ends_at, onChange: (nextValue) => setBatchForm((current) => ({ ...current, ends_at: nextValue })), placeholder: "End time", clearable: true, className: "bg-white/[0.04]" }))), /* @__PURE__ */ React.createElement(Field$1, { label: "Placement Notes" }, /* @__PURE__ */ React.createElement("textarea", { value: batchForm.notes, onChange: (event) => setBatchForm((current) => ({ ...current, notes: event.target.value })), className: "mt-4 min-h-[110px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none", maxLength: 1e3 })), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => handleBatchEditorial("preview"), disabled: busy === "batch-preview", className: "inline-flex items-center gap-2 rounded-2xl border border-lime-300/20 bg-lime-400/10 px-5 py-3 text-sm font-semibold text-lime-100 transition hover:bg-lime-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "batch-preview" ? "fa-circle-notch fa-spin" : "fa-flask"} fa-fw` }), "Preview Batch"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => handleBatchEditorial("apply"), disabled: busy === "batch-apply", className: "inline-flex items-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === "batch-apply" ? "fa-circle-notch fa-spin" : "fa-wand-magic-sparkles"} fa-fw` }), "Apply Batch"))), batchResult ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[26px] border border-white/10 bg-slate-950/40 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-white" }, "Preview results"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-300" }, batchResult.collections_count, " collections reviewed, ", batchResult.placement_eligible_count, " placement-ready."))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, (batchResult.items || []).map((item) => /* @__PURE__ */ React.createElement("div", { key: item.collection?.id, className: "rounded-[22px] border border-white/10 bg-white/[0.04] p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-sm font-semibold text-white" }, item.collection?.title), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-xs text-slate-400" }, item.collection?.visibility, " · ", item.collection?.lifecycle_state, " · ", item.collection?.moderation_status)), item.placement ? /* @__PURE__ */ React.createElement("span", { className: `rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${item.placement.eligible ? "border-lime-300/20 bg-lime-400/10 text-lime-100" : "border-rose-300/20 bg-rose-400/10 text-rose-100"}` }, item.placement.eligible ? `ready for ${item.placement.surface_key}` : "placement skipped") : /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, "metadata only")), item.eligibility?.reasons?.length ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-xs text-amber-100/80" }, "Campaign readiness: ", item.eligibility.reasons.join(" ")) : null, item.placement?.reasons?.length ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs text-rose-100/80" }, "Placement: ", item.placement.reasons.join(" ")) : null)))) : null))), /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Definitions"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Registered surfaces")), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300" }, definitions.length)), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 lg:grid-cols-2" }, definitions.map((definition2) => /* @__PURE__ */ React.createElement("div", { key: definition2.id, className: "rounded-[24px] border border-white/10 bg-slate-950/40 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100" }, definition2.surface_key), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, definition2.mode)), /* @__PURE__ */ React.createElement("h3", { className: "mt-4 text-lg font-semibold text-white" }, definition2.title), definition2.description ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-300" }, definition2.description) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2 text-xs text-slate-400" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1" }, definition2.ranking_mode), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1" }, "max ", definition2.max_items), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1" }, definition2.is_active ? "active" : "inactive"), definition2.starts_at ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1" }, "starts ", new Date(definition2.starts_at).toLocaleString()) : null, definition2.ends_at ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1" }, "ends ", new Date(definition2.ends_at).toLocaleString()) : null, definition2.fallback_surface_key ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1" }, "fallback ", definition2.fallback_surface_key) : null), /* @__PURE__ */ React.createElement("div", { className: "mt-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => hydrateDefinition(definition2), className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-pen fa-fw text-[10px]" }), "Edit Definition"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => handleDeleteDefinition(definition2), disabled: busy === `delete-definition-${definition2.id}`, className: "inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-xs font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === `delete-definition-${definition2.id}` ? "fa-circle-notch fa-spin" : "fa-trash"} fa-fw text-[10px]` }), "Delete"))))))), conflicts.length ? /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[32px] border border-rose-300/20 bg-rose-500/10 p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-rose-100/80" }, "Conflicts"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Schedule overlaps need review")), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-xs font-semibold text-rose-100" }, conflicts.length)), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 lg:grid-cols-2" }, conflicts.map((conflict, index2) => /* @__PURE__ */ React.createElement("div", { key: `${conflict.surface_key}-${index2}`, className: "rounded-[24px] border border-rose-300/20 bg-slate-950/40 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-100" }, conflict.surface_key)), /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm text-rose-50" }, conflict.summary), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-xs text-rose-100/70" }, "Window: ", conflict.window?.starts_at ? new Date(conflict.window.starts_at).toLocaleString() : "Immediate", " to ", conflict.window?.ends_at ? new Date(conflict.window.ends_at).toLocaleString() : "Open-ended"))))) : null, /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80" }, "Placements"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Active and scheduled slots")), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300" }, placements.length)), /* @__PURE__ */ React.createElement("div", { className: "mt-6 space-y-5" }, placements.map((placement) => /* @__PURE__ */ React.createElement("div", { key: placement.id, className: "rounded-[28px] border border-white/10 bg-slate-950/40 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100" }, placement.surface_key), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, placement.placement_type), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300" }, "priority ", placement.priority), conflictPlacementIds.has(placement.id) || placement.has_conflict ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-100" }, "conflict") : null), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => hydratePlacement(placement), className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-pen fa-fw text-[10px]" }), "Edit"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => handleDeletePlacement(placement), disabled: busy === `delete-placement-${placement.id}`, className: "inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-xs font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${busy === `delete-placement-${placement.id}` ? "fa-circle-notch fa-spin" : "fa-trash"} fa-fw text-[10px]` }), "Delete"))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-5 xl:grid-cols-[minmax(0,1fr)_280px]" }, /* @__PURE__ */ React.createElement("div", null, placement.collection ? /* @__PURE__ */ React.createElement(CollectionCard, { collection: placement.collection, isOwner: true }) : null), /* @__PURE__ */ React.createElement("div", { className: "space-y-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3" }, "Starts: ", placement.starts_at ? new Date(placement.starts_at).toLocaleString() : "Immediate"), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3" }, "Ends: ", placement.ends_at ? new Date(placement.ends_at).toLocaleString() : "Open-ended"), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3" }, "Campaign: ", placement.campaign_key || "None"), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3" }, "Status: ", placement.is_active ? "Active" : "Inactive"), placement.notes ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3 text-slate-300" }, placement.notes) : null))))))))); } -const __vite_glob_0_38 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_48 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: CollectionStaffSurfaces }, Symbol.toStringTag, { value: "Module" })); @@ -76372,7 +79239,7 @@ function NovaCardCanvasPreview({ card, fonts = [], className = "", editable = fa ); })); } -function requestJson$f(url, { method = "GET", body: body2 } = {}) { +function requestJson$h(url, { method = "GET", body: body2 } = {}) { return fetch(url, { method, credentials: "same-origin", @@ -76432,7 +79299,7 @@ function NovaCardsAdminIndex() { setReportsError(""); try { const separator = String(endpoints.reportsQueue).includes("?") ? "&" : "?"; - const response = await requestJson$f(`${endpoints.reportsQueue}${separator}status=${reportStatus}`); + const response = await requestJson$h(`${endpoints.reportsQueue}${separator}status=${reportStatus}`); if (!active) return; setReports(response.data || []); setReportsMeta(response.meta || { total: 0 }); @@ -76471,7 +79338,7 @@ function NovaCardsAdminIndex() { }; }, [endpoints.reportsQueue, reportStatus]); async function updateCard(cardId, patch2) { - const response = await requestJson$f(String(endpoints.updateCardPattern || "").replace("__CARD__", String(cardId)), { + const response = await requestJson$h(String(endpoints.updateCardPattern || "").replace("__CARD__", String(cardId)), { method: "PATCH", body: patch2 }); @@ -76482,7 +79349,7 @@ function NovaCardsAdminIndex() { setCards((current) => current.map((card) => card.id === cardId ? response.card : card)); } async function updateCreator(creatorId, patch2) { - const response = await requestJson$f(String(endpoints.updateCreatorPattern || "").replace("__CREATOR__", String(creatorId)), { + const response = await requestJson$h(String(endpoints.updateCreatorPattern || "").replace("__CREATOR__", String(creatorId)), { method: "PATCH", body: patch2 }); @@ -76517,7 +79384,7 @@ function NovaCardsAdminIndex() { async function saveCategory(category) { const isExisting = Boolean(category.id); const url = isExisting ? String(endpoints.updateCategoryPattern || "").replace("__CATEGORY__", String(category.id)) : endpoints.storeCategory; - const response = await requestJson$f(url, { + const response = await requestJson$h(url, { method: isExisting ? "PATCH" : "POST", body: category }); @@ -76538,7 +79405,7 @@ function NovaCardsAdminIndex() { } setReportBusy((current) => ({ ...current, [reportId]: true })); try { - const response = await requestJson$f(String(endpoints.updateReportPattern || "").replace("__REPORT__", String(reportId)), { + const response = await requestJson$h(String(endpoints.updateReportPattern || "").replace("__REPORT__", String(reportId)), { method: "PATCH", body: patch2 }); @@ -76554,7 +79421,7 @@ function NovaCardsAdminIndex() { } setReportBusy((current) => ({ ...current, [reportId]: true })); try { - const response = await requestJson$f(String(endpoints.moderateReportTargetPattern || "").replace("__REPORT__", String(reportId)), { + const response = await requestJson$h(String(endpoints.moderateReportTargetPattern || "").replace("__REPORT__", String(reportId)), { method: "POST", body: { action, disposition: reportDispositions[reportId] || null } }); @@ -76651,11 +79518,11 @@ function NovaCardsAdminIndex() { } )), /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Featured"), /* @__PURE__ */ React.createElement(Checkbox, { checked: Boolean(card.featured), onChange: (event) => updateCard(card.id, { featured: event.target.checked }) })), /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Allow remix"), /* @__PURE__ */ React.createElement(Checkbox, { checked: Boolean(card.allow_remix), onChange: (event) => updateCard(card.id, { allow_remix: event.target.checked }) }))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-4 text-xs text-slate-400" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3" }, card.likes_count || 0, " likes"), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3" }, card.saves_count || 0, " saves"), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3" }, card.remixes_count || 0, " remixes"), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3" }, card.challenge_entries_count || 0, " challenge entries")), card.moderation_reason_labels?.length ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 rounded-2xl border border-amber-300/15 bg-amber-400/10 px-4 py-3 text-sm text-amber-50" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80" }, "Heuristic moderation flags"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 flex flex-wrap gap-2" }, card.moderation_reason_labels.map((label) => /* @__PURE__ */ React.createElement("span", { key: `${card.id}-${label}`, className: "rounded-full border border-amber-200/20 bg-amber-50/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-50" }, label))), card.moderation_source ? /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-[11px] uppercase tracking-[0.14em] text-amber-100/70" }, "Source ", String(card.moderation_source).replaceAll("_", " ")) : null) : null, card.moderation_override ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 rounded-2xl border border-sky-300/15 bg-sky-400/10 px-4 py-3 text-sm text-sky-50" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/80" }, "Latest staff override"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 flex flex-wrap gap-2 text-xs uppercase tracking-[0.14em] text-sky-100/80" }, /* @__PURE__ */ React.createElement("span", null, "Status ", card.moderation_override.moderation_status), card.moderation_override.disposition_label ? /* @__PURE__ */ React.createElement("span", null, card.moderation_override.disposition_label) : null, card.moderation_override.actor_username ? /* @__PURE__ */ React.createElement("span", null, "@", card.moderation_override.actor_username) : null, card.moderation_override.source ? /* @__PURE__ */ React.createElement("span", null, String(card.moderation_override.source).replaceAll("_", " ")) : null), card.moderation_override.note ? /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-sm leading-6 text-sky-50" }, card.moderation_override.note) : null) : null, card.moderation_override_history?.length > 1 ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 rounded-2xl border border-sky-300/10 bg-sky-400/[0.08] px-4 py-3 text-sm text-sky-50" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/75" }, "Recent override history"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2" }, renderOverrideHistoryItems(card.moderation_override_history, `card-${card.id}`))) : null))))), /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]" }, /* @__PURE__ */ React.createElement("div", { className: "mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400" }, "Creator curation"), !featuredCreators.length ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-400" }, "No public Nova creators are available for curation yet.") : null, /* @__PURE__ */ React.createElement("div", { className: "space-y-3" }, featuredCreators.map((creator) => /* @__PURE__ */ React.createElement("div", { key: creator.id, className: "rounded-2xl border border-white/10 bg-white/[0.03] p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "font-semibold text-white" }, creator.display_name), /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.18em] text-slate-500" }, "@", creator.username)), creator.public_url ? /* @__PURE__ */ React.createElement("a", { href: creator.public_url, className: "text-xs font-semibold uppercase tracking-[0.16em] text-sky-300 transition hover:text-sky-200" }, "Open profile") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-3 grid grid-cols-3 gap-2 text-xs text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-[#08111f]/70 px-3 py-3" }, creator.public_cards_count || 0, " public cards"), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-[#08111f]/70 px-3 py-3" }, creator.featured_cards_count || 0, " featured"), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-[#08111f]/70 px-3 py-3" }, creator.total_views_count || 0, " views")), /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Feature on editorial page"), /* @__PURE__ */ React.createElement(Checkbox, { checked: Boolean(creator.nova_featured_creator), onChange: (event) => updateCreator(creator.id, { nova_featured_creator: event.target.checked }) })))))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]" }, /* @__PURE__ */ React.createElement("div", { className: "mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400" }, "Categories"), /* @__PURE__ */ React.createElement("div", { className: "space-y-3" }, categories.map((category) => /* @__PURE__ */ React.createElement("div", { key: category.id, className: "rounded-2xl border border-white/10 bg-white/[0.03] p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "font-semibold text-white" }, category.name), /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.18em] text-slate-500" }, category.slug, " • ", category.cards_count, " cards")), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => saveCategory(category), className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.08]" }, "Save")))))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]" }, /* @__PURE__ */ React.createElement("div", { className: "mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400" }, "Add category"), /* @__PURE__ */ React.createElement("div", { className: "space-y-3" }, /* @__PURE__ */ React.createElement("input", { value: newCategory.name, onChange: (event) => setNewCategory((current) => ({ ...current, name: event.target.value })), placeholder: "Name", className: "w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" }), /* @__PURE__ */ React.createElement("input", { value: newCategory.slug, onChange: (event) => setNewCategory((current) => ({ ...current, slug: event.target.value })), placeholder: "Slug", className: "w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" }), /* @__PURE__ */ React.createElement("textarea", { value: newCategory.description, onChange: (event) => setNewCategory((current) => ({ ...current, description: event.target.value })), placeholder: "Description", rows: 3, className: "w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" }), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => saveCategory(newCategory), className: "w-full rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15" }, "Create category")))))); } -const __vite_glob_0_40 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_50 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: NovaCardsAdminIndex }, Symbol.toStringTag, { value: "Module" })); -function requestJson$e(url, { method = "GET", body: body2 } = {}) { +function requestJson$g(url, { method = "GET", body: body2 } = {}) { return fetch(url, { method, credentials: "same-origin", @@ -76699,7 +79566,7 @@ function NovaCardsAssetPackAdmin() { async function savePack() { const isExisting = Boolean(selectedId); const url = isExisting ? String(endpoints.updatePattern || "").replace("__PACK__", String(selectedId)) : endpoints.store; - const response = await requestJson$e(url, { method: isExisting ? "PATCH" : "POST", body: form }); + const response = await requestJson$g(url, { method: isExisting ? "PATCH" : "POST", body: form }); if (isExisting) { setPacks((current) => current.map((pack) => pack.id === selectedId ? response.pack : pack)); } else { @@ -76714,11 +79581,11 @@ function NovaCardsAssetPackAdmin() { } }, rows: 10, className: "rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 font-mono text-sm text-white md:col-span-2" })), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: Boolean(form.active), onChange: (event) => setForm((current) => ({ ...current, active: event.target.checked })), label: "Active" }), /* @__PURE__ */ React.createElement(Checkbox, { checked: Boolean(form.official), onChange: (event) => setForm((current) => ({ ...current, official: event.target.checked })), label: "Official" })), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: savePack, className: "mt-5 w-full rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15" }, selectedId ? "Update pack" : "Create pack")))); } -const __vite_glob_0_41 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_51 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: NovaCardsAssetPackAdmin }, Symbol.toStringTag, { value: "Module" })); -function requestJson$d(url, { method = "GET", body: body2 } = {}) { +function requestJson$f(url, { method = "GET", body: body2 } = {}) { return fetch(url, { method, credentials: "same-origin", @@ -76765,7 +79632,7 @@ function NovaCardsChallengeAdmin() { async function saveChallenge() { const isExisting = Boolean(selectedId); const url = isExisting ? String(endpoints.updatePattern || "").replace("__CHALLENGE__", String(selectedId)) : endpoints.store; - const response = await requestJson$d(url, { method: isExisting ? "PATCH" : "POST", body: { ...form, winner_card_id: form.winner_card_id || null } }); + const response = await requestJson$f(url, { method: isExisting ? "PATCH" : "POST", body: { ...form, winner_card_id: form.winner_card_id || null } }); if (isExisting) { setChallenges((current) => current.map((challenge) => challenge.id === selectedId ? response.challenge : challenge)); } else { @@ -76780,11 +79647,11 @@ function NovaCardsChallengeAdmin() { } }, rows: 10, className: "rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 font-mono text-sm text-white md:col-span-2" })), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: Boolean(form.official), onChange: (event) => setForm((current) => ({ ...current, official: event.target.checked })), label: "Official" }), /* @__PURE__ */ React.createElement(Checkbox, { checked: Boolean(form.featured), onChange: (event) => setForm((current) => ({ ...current, featured: event.target.checked })), label: "Featured" })), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: saveChallenge, className: "mt-5 w-full rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15" }, selectedId ? "Update challenge" : "Create challenge")))); } -const __vite_glob_0_42 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_52 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: NovaCardsChallengeAdmin }, Symbol.toStringTag, { value: "Module" })); -function requestJson$c(url, { method = "GET", body: body2 } = {}) { +function requestJson$e(url, { method = "GET", body: body2 } = {}) { return fetch(url, { method, credentials: "same-origin", @@ -76838,7 +79705,7 @@ function NovaCardsCollectionAdmin() { async function saveCollection() { const isExisting = Boolean(selectedId); const url = isExisting ? String(endpoints.updatePattern || "").replace("__COLLECTION__", String(selectedId)) : endpoints.store; - const response = await requestJson$c(url, { method: isExisting ? "PATCH" : "POST", body: form }); + const response = await requestJson$e(url, { method: isExisting ? "PATCH" : "POST", body: form }); if (isExisting) { setCollections((current) => current.map((entry) => entry.id === selectedId ? response.collection : entry)); } else { @@ -76848,7 +79715,7 @@ function NovaCardsCollectionAdmin() { } async function attachCard() { if (!selectedId || !cardId) return; - const response = await requestJson$c(String(endpoints.attachCardPattern || "").replace("__COLLECTION__", String(selectedId)), { + const response = await requestJson$e(String(endpoints.attachCardPattern || "").replace("__COLLECTION__", String(selectedId)), { method: "POST", body: { card_id: Number(cardId), note: cardNote || null } }); @@ -76857,7 +79724,7 @@ function NovaCardsCollectionAdmin() { setCardNote(""); } async function detachCard(collectionId, currentCardId) { - const response = await requestJson$c( + const response = await requestJson$e( String(endpoints.detachCardPattern || "").replace("__COLLECTION__", String(collectionId)).replace("__CARD__", String(currentCardId)), { method: "DELETE" } ); @@ -76865,11 +79732,11 @@ function NovaCardsCollectionAdmin() { } return /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-7xl px-4 pb-20 pt-8 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(Se$1, { title: "Nova Cards Collections" }), /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75" }, "Editorial layer"), /* @__PURE__ */ React.createElement("h1", { className: "mt-3 text-3xl font-semibold tracking-[-0.04em] text-white" }, "Official and public card collections"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 max-w-3xl text-sm leading-7 text-slate-300" }, "Create editorial collections, assign owners, and curate the public card sets that the v2 browse surface links to.")), /* @__PURE__ */ React.createElement("div", { className: "flex gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => setSelectedId(null), className: "rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, "New collection"), /* @__PURE__ */ React.createElement(xe, { href: endpoints.cards || "/cp/cards", className: "rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, "Back to cards")))), /* @__PURE__ */ React.createElement("div", { className: "mt-8 grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]" }, /* @__PURE__ */ React.createElement("div", { className: "mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400" }, "Collections"), /* @__PURE__ */ React.createElement("div", { className: "space-y-3" }, collections.map((collection) => /* @__PURE__ */ React.createElement("button", { key: collection.id, type: "button", onClick: () => setSelectedId(collection.id), className: `w-full rounded-[22px] border p-4 text-left transition ${selectedId === collection.id ? "border-sky-300/35 bg-sky-400/10" : "border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]"}` }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-base font-semibold tracking-[-0.03em] text-white" }, collection.name), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs uppercase tracking-[0.18em] text-slate-500" }, collection.featured ? "Featured • " : "", collection.official ? "Official" : "@" + (collection.owner?.username || "creator"))), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-200" }, collection.cards_count, " cards")), collection.description ? /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-sm text-slate-400" }, collection.description) : null)))), /* @__PURE__ */ React.createElement("section", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]" }, /* @__PURE__ */ React.createElement("div", { className: "mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400" }, "Collection editor"), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "mb-2 block" }, "Owner"), /* @__PURE__ */ React.createElement(NovaSelect, { value: form.user_id, onChange: (val) => setForm((current) => ({ ...current, user_id: Number(val) })), options: admins.map((a) => ({ value: a.id, label: a.name || a.username })) })), /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "mb-2 block" }, "Visibility"), /* @__PURE__ */ React.createElement(NovaSelect, { value: form.visibility, onChange: (val) => setForm((current) => ({ ...current, visibility: val })), searchable: false, options: [{ value: "public", label: "public" }, { value: "private", label: "private" }] })), /* @__PURE__ */ React.createElement("input", { value: form.name, onChange: (event) => setForm((current) => ({ ...current, name: event.target.value })), placeholder: "Collection name", className: "rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" }), /* @__PURE__ */ React.createElement("input", { value: form.slug, onChange: (event) => setForm((current) => ({ ...current, slug: event.target.value })), placeholder: "Slug", className: "rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" }), /* @__PURE__ */ React.createElement("textarea", { value: form.description, onChange: (event) => setForm((current) => ({ ...current, description: event.target.value })), placeholder: "Description", rows: 4, className: "rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" })), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-4" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: Boolean(form.official), onChange: (event) => setForm((current) => ({ ...current, official: event.target.checked })), label: "Official collection" }), /* @__PURE__ */ React.createElement(Checkbox, { checked: Boolean(form.featured), onChange: (event) => setForm((current) => ({ ...current, featured: event.target.checked })), label: "Featured collection" })), selected?.public_url ? /* @__PURE__ */ React.createElement("a", { href: selected.public_url, className: "text-sky-100 transition hover:text-white", target: "_blank", rel: "noreferrer" }, "Open public page") : null), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: saveCollection, className: "mt-5 w-full rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15" }, selectedId ? "Update collection" : "Create collection")), /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]" }, /* @__PURE__ */ React.createElement("div", { className: "mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400" }, "Curate cards"), !selectedId ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-dashed border-white/12 bg-white/[0.03] px-4 py-8 text-center text-sm text-slate-400" }, "Create or select a collection first.") : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: String(cardId || ""), onChange: (val) => setCardId(val), placeholder: "Select a card", options: cards.map((c) => ({ value: String(c.id), label: c.title })) }), /* @__PURE__ */ React.createElement("input", { value: cardNote, onChange: (event) => setCardNote(event.target.value), placeholder: "Optional curator note", className: "rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" }), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: attachCard, className: "rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15" }, "Add")), /* @__PURE__ */ React.createElement("div", { className: "mt-5 space-y-3" }, (selected?.items || []).map((item) => /* @__PURE__ */ React.createElement("div", { key: item.id, className: "flex items-start justify-between gap-4 rounded-[22px] border border-white/10 bg-white/[0.03] p-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-base font-semibold text-white" }, item.card?.title), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs uppercase tracking-[0.18em] text-slate-500" }, "#", item.sort_order, " ", item.card?.creator?.username ? `• @${item.card.creator.username}` : ""), item.note ? /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-sm text-slate-400" }, item.note) : null), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => detachCard(selectedId, item.card.id), className: "rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15" }, "Remove"))))))))); } -const __vite_glob_0_43 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_53 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: NovaCardsCollectionAdmin }, Symbol.toStringTag, { value: "Module" })); -function requestJson$b(url, { method = "GET", body: body2 } = {}) { +function requestJson$d(url, { method = "GET", body: body2 } = {}) { return fetch(url, { method, credentials: "same-origin", @@ -76948,7 +79815,7 @@ function NovaCardsTemplateAdmin() { async function saveTemplate() { const isExisting = Boolean(selectedId); const url = isExisting ? String(endpoints.updatePattern || "").replace("__TEMPLATE__", String(selectedId)) : endpoints.store; - const response = await requestJson$b(url, { + const response = await requestJson$d(url, { method: isExisting ? "PATCH" : "POST", body: form }); @@ -76970,22 +79837,22 @@ function NovaCardsTemplateAdmin() { } return /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-7xl px-4 pb-20 pt-8 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(Se$1, { title: "Nova Cards Templates" }), /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75" }, "Template system"), /* @__PURE__ */ React.createElement("h1", { className: "mt-3 text-3xl font-semibold tracking-[-0.04em] text-white" }, "Official Nova Cards templates"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 max-w-3xl text-sm leading-7 text-slate-300" }, "Keep starter templates config-driven so the editor and render pipeline stay aligned as new card styles ship.")), /* @__PURE__ */ React.createElement("div", { className: "flex gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: resetForm, className: "rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, "New template"), /* @__PURE__ */ React.createElement(xe, { href: endpoints.cards || "/cp/cards", className: "rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, "Back to cards")))), /* @__PURE__ */ React.createElement("div", { className: "mt-8 grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1.4fr)]" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]" }, /* @__PURE__ */ React.createElement("div", { className: "mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400" }, "Existing templates"), /* @__PURE__ */ React.createElement("div", { className: "space-y-3" }, templates.map((template) => /* @__PURE__ */ React.createElement("button", { key: template.id, type: "button", onClick: () => loadTemplate(template), className: `w-full rounded-[22px] border p-4 text-left transition ${selectedId === template.id ? "border-sky-300/35 bg-sky-400/10" : "border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]"}` }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-base font-semibold tracking-[-0.03em] text-white" }, template.name), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs uppercase tracking-[0.18em] text-slate-500" }, template.slug)), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-200" }, template.supported_formats?.join(", "))), template.description ? /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-sm text-slate-400" }, template.description) : null)))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]" }, /* @__PURE__ */ React.createElement("div", { className: "mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400" }, "Template editor"), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("input", { value: form.name, onChange: (event) => setForm((current) => ({ ...current, name: event.target.value })), placeholder: "Template name", className: "rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" }), /* @__PURE__ */ React.createElement("input", { value: form.slug, onChange: (event) => setForm((current) => ({ ...current, slug: event.target.value })), placeholder: "Slug", className: "rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" }), /* @__PURE__ */ React.createElement("textarea", { value: form.description, onChange: (event) => setForm((current) => ({ ...current, description: event.target.value })), placeholder: "Description", rows: 3, className: "rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" }), /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "mb-2 block" }, "Font preset"), /* @__PURE__ */ React.createElement(NovaSelect, { value: form.config_json?.font_preset || "modern-sans", onChange: (val) => setForm((current) => ({ ...current, config_json: { ...current.config_json, font_preset: val } })), options: fonts.map((f2) => ({ value: f2.key, label: f2.label })), searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "mb-2 block" }, "Gradient preset"), /* @__PURE__ */ React.createElement(NovaSelect, { value: form.config_json?.gradient_preset || "midnight-nova", onChange: (val) => setForm((current) => ({ ...current, config_json: { ...current.config_json, gradient_preset: val } })), options: gradients.map((g2) => ({ value: g2.key, label: g2.label })), searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "mb-2 block" }, "Layout preset"), /* @__PURE__ */ React.createElement(NovaSelect, { value: form.config_json?.layout || "quote_heavy", onChange: (val) => setForm((current) => ({ ...current, config_json: { ...current.config_json, layout: val } })), options: ["quote_heavy", "author_emphasis", "centered", "minimal"].map((v2) => ({ value: v2, label: v2 })), searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "mb-2 block" }, "Text alignment"), /* @__PURE__ */ React.createElement(NovaSelect, { value: form.config_json?.text_align || "center", onChange: (val) => setForm((current) => ({ ...current, config_json: { ...current.config_json, text_align: val } })), options: ["left", "center", "right"].map((v2) => ({ value: v2, label: v2 })), searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "mb-2 block" }, "Overlay style"), /* @__PURE__ */ React.createElement(NovaSelect, { value: form.config_json?.overlay_style || "dark-soft", onChange: (val) => setForm((current) => ({ ...current, config_json: { ...current.config_json, overlay_style: val } })), options: ["none", "dark-soft", "dark-strong", "light-soft"].map((v2) => ({ value: v2, label: v2 })), searchable: false })), /* @__PURE__ */ React.createElement("label", { className: "text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "mb-2 block" }, "Text color"), /* @__PURE__ */ React.createElement("input", { type: "color", value: form.config_json?.text_color || "#ffffff", onChange: (event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, text_color: event.target.value } })), className: "h-12 w-full rounded-2xl border border-white/10 bg-[#0d1726] p-2" }))), /* @__PURE__ */ React.createElement("div", { className: "mt-5" }, /* @__PURE__ */ React.createElement("div", { className: "mb-3 text-sm font-semibold uppercase tracking-[0.18em] text-slate-400" }, "Supported formats"), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, formats2.map((format) => /* @__PURE__ */ React.createElement("div", { key: format.key, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: form.supported_formats.includes(format.key), onChange: () => toggleFormat(format.key), label: format.label }))))), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: Boolean(form.active), onChange: (event) => setForm((current) => ({ ...current, active: event.target.checked })), label: "Active" }), /* @__PURE__ */ React.createElement(Checkbox, { checked: Boolean(form.official), onChange: (event) => setForm((current) => ({ ...current, official: event.target.checked })), label: "Official" })), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: saveTemplate, className: "mt-5 w-full rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15" }, selectedId ? "Update template" : "Create template")))); } -const __vite_glob_0_44 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_54 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: NovaCardsTemplateAdmin }, Symbol.toStringTag, { value: "Module" })); -function getCsrfToken$4() { +function getCsrfToken$6() { if (typeof document === "undefined") return ""; return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } -async function requestJson$a(url, { method = "POST", body: body2 } = {}) { +async function requestJson$c(url, { method = "POST", body: body2 } = {}) { const response = await fetch(url, { method, credentials: "same-origin", headers: { Accept: "application/json", "Content-Type": "application/json", - "X-CSRF-TOKEN": getCsrfToken$4(), + "X-CSRF-TOKEN": getCsrfToken$6(), "X-Requested-With": "XMLHttpRequest" }, body: body2 ? JSON.stringify(body2) : void 0 @@ -77089,7 +79956,7 @@ function SavedCollections() { setBusy("create-list"); setNotice(""); try { - const payload = await requestJson$a(props.endpoints.createList, { + const payload = await requestJson$c(props.endpoints.createList, { method: "POST", body: { title: newListTitle.trim() } }); @@ -77108,7 +79975,7 @@ function SavedCollections() { setBusy(`list-${collectionId}`); setNotice(""); try { - const payload = await requestJson$a(props.endpoints.addToListPattern.replace("__COLLECTION__", String(collectionId)), { + const payload = await requestJson$c(props.endpoints.addToListPattern.replace("__COLLECTION__", String(collectionId)), { method: "POST", body: { saved_list_id: Number(listId) } }); @@ -77125,7 +79992,7 @@ function SavedCollections() { setBusy(`unsave-${collection.id}`); setNotice(""); try { - await requestJson$a(props.endpoints.unsavePattern.replace("__COLLECTION__", String(collection.id)), { + await requestJson$c(props.endpoints.unsavePattern.replace("__COLLECTION__", String(collection.id)), { method: "DELETE" }); setCollections((current) => current.filter((item) => Number(item.id) !== Number(collection.id))); @@ -77145,7 +80012,7 @@ function SavedCollections() { setBusy(`remove-${collection.id}`); setNotice(""); try { - const payload = await requestJson$a( + const payload = await requestJson$c( props.endpoints.removeFromListPattern.replace("__LIST__", String(activeList.id)).replace("__COLLECTION__", String(collection.id)), { method: "DELETE" } ); @@ -77165,7 +80032,7 @@ function SavedCollections() { setBusy(`reorder-${collectionId}`); setNotice(""); try { - await requestJson$a( + await requestJson$c( props.endpoints.reorderItemsPattern.replace("__LIST__", String(activeList.id)), { method: "POST", @@ -77192,7 +80059,7 @@ function SavedCollections() { setBusy(`note-${collectionId}`); setNotice(""); try { - const payload = await requestJson$a( + const payload = await requestJson$c( props.endpoints.updateNotePattern.replace("__COLLECTION__", String(collectionId)), { method: "PATCH", @@ -77256,7 +80123,7 @@ function SavedCollections() { } ))))) : activeFilters.q || activeFilters.filter !== "all" || activeFilters.sort !== "saved_desc" || activeFilters.list ? /* @__PURE__ */ React.createElement("div", { className: "rounded-[32px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-16 text-center text-sm text-slate-300" }, "No saved collections match the current search or filters.") : /* @__PURE__ */ React.createElement(EmptyState$3, { browseUrl })), recommendedCollections.length ? /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80" }, "Recommended Next"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Because of what you save")), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300" }, recommendedCollections.length)), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid grid-cols-1 gap-5 xl:grid-cols-3" }, recommendedCollections.map((collection) => /* @__PURE__ */ React.createElement(CollectionCard, { key: collection.id, collection, isOwner: false })))) : null))))); } -const __vite_glob_0_45 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_55 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: SavedCollections }, Symbol.toStringTag, { value: "Module" })); @@ -77594,20 +80461,24 @@ if (typeof document !== "undefined") { clientExports.createRoot(mountEl).render(/* @__PURE__ */ React.createElement(CommunityActivityPage, { ...props })); } } -const __vite_glob_0_46 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_56 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: CommunityActivityPage }, Symbol.toStringTag, { value: "Module" })); function Pagination({ meta, onPageChange }) { - if (!meta || meta.last_page <= 1) return null; - const { current_page, last_page } = meta; + if (!meta) return null; + const currentPage = Number(meta.current_page || 1); + const lastPage = meta.last_page != null ? Number(meta.last_page) : null; + const hasMore = Boolean(meta.has_more); + if (lastPage !== null && lastPage <= 1) return null; + if (lastPage === null && currentPage <= 1 && !hasMore) return null; const pages2 = []; - if (last_page <= 7) { - for (let i = 1; i <= last_page; i++) pages2.push(i); - } else { + if (lastPage !== null && lastPage <= 7) { + for (let i = 1; i <= lastPage; i++) pages2.push(i); + } else if (lastPage !== null) { const around = new Set( - [1, last_page, current_page, current_page - 1, current_page + 1].filter( - (p) => p >= 1 && p <= last_page + [1, lastPage, currentPage, currentPage - 1, currentPage + 1].filter( + (p) => p >= 1 && p <= lastPage ) ); const sorted = [...around].sort((a, b2) => a - b2); @@ -77625,33 +80496,33 @@ function Pagination({ meta, onPageChange }) { /* @__PURE__ */ React.createElement( "button", { - disabled: current_page <= 1, - onClick: () => onPageChange(current_page - 1), + disabled: currentPage <= 1, + onClick: () => onPageChange(currentPage - 1), className: "px-3 py-1.5 rounded-md text-sm text-white/50 hover:text-white hover:bg-white/[0.06] disabled:opacity-25 disabled:pointer-events-none transition-colors", "aria-label": "Previous page" }, "‹ Prev" ), - pages2.map( + lastPage !== null ? pages2.map( (p, i) => p === "…" ? /* @__PURE__ */ React.createElement("span", { key: `sep-${i}`, className: "px-2 text-white/25 text-sm select-none" }, "…") : /* @__PURE__ */ React.createElement( "button", { key: p, - onClick: () => p !== current_page && onPageChange(p), - "aria-current": p === current_page ? "page" : void 0, + onClick: () => p !== currentPage && onPageChange(p), + "aria-current": p === currentPage ? "page" : void 0, className: [ "min-w-[2rem] px-3 py-1.5 rounded-md text-sm font-medium transition-colors", - p === current_page ? "bg-sky-600/30 text-sky-300 ring-1 ring-sky-500/40" : "text-white/50 hover:text-white hover:bg-white/[0.06]" + p === currentPage ? "bg-sky-600/30 text-sky-300 ring-1 ring-sky-500/40" : "text-white/50 hover:text-white hover:bg-white/[0.06]" ].join(" ") }, p ) - ), + ) : /* @__PURE__ */ React.createElement("span", { className: "px-2 text-sm text-white/35" }, "Page ", currentPage), /* @__PURE__ */ React.createElement( "button", { - disabled: current_page >= last_page, - onClick: () => onPageChange(current_page + 1), + disabled: lastPage !== null ? currentPage >= lastPage : !hasMore, + onClick: () => onPageChange(currentPage + 1), className: "px-3 py-1.5 rounded-md text-sm text-white/50 hover:text-white hover:bg-white/[0.06] disabled:opacity-25 disabled:pointer-events-none transition-colors", "aria-label": "Next page" }, @@ -77912,7 +80783,7 @@ if (typeof document !== "undefined") { clientExports.createRoot(mountEl).render(/* @__PURE__ */ React.createElement(LatestCommentsPage, { ...props })); } } -const __vite_glob_0_47 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_57 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: LatestCommentsPage }, Symbol.toStringTag, { value: "Module" })); @@ -78583,7 +81454,7 @@ function FollowingFeed() { loading ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-spinner fa-spin mr-2" }), "Loading…") : "Load more" ))))); } -const __vite_glob_0_48 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_58 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: FollowingFeed }, Symbol.toStringTag, { value: "Module" })); @@ -78643,7 +81514,7 @@ function HashtagFeed() { loading ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-spinner fa-spin mr-2" }), "Loading…") : "Load more" )))))); } -const __vite_glob_0_49 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_59 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: HashtagFeed }, Symbol.toStringTag, { value: "Module" })); @@ -78701,7 +81572,7 @@ function SavedFeed() { loading ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-spinner fa-spin mr-2" }), "Loading…") : "Load more" )))))); } -const __vite_glob_0_50 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_60 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: SavedFeed }, Symbol.toStringTag, { value: "Module" })); @@ -78826,7 +81697,7 @@ function SearchFeed() { "Load more" ))), /* @__PURE__ */ React.createElement("aside", { className: "hidden lg:block w-64 shrink-0 space-y-4 pt-14" }, /* @__PURE__ */ React.createElement(TrendingHashtagsSidebar$1, { hashtags: trendingHashtags }), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/[0.07] bg-white/[0.03] px-4 py-4 text-center" }, /* @__PURE__ */ React.createElement("p", { className: "text-xs text-slate-500 leading-relaxed" }, "Tip: search ", /* @__PURE__ */ React.createElement("span", { className: "text-sky-400/80" }, "#hashtag"), " to find posts by topic."))))))); } -const __vite_glob_0_51 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_61 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: SearchFeed }, Symbol.toStringTag, { value: "Module" })); @@ -78888,7 +81759,7 @@ function TrendingFeed() { loading ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-spinner fa-spin mr-2" }), "Loading…") : "Load more" ))), /* @__PURE__ */ React.createElement("aside", { className: "hidden lg:block w-64 shrink-0 space-y-4 pt-14" }, /* @__PURE__ */ React.createElement(TrendingHashtagsSidebar, { hashtags: trendingHashtags }), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/[0.07] bg-white/[0.03] px-4 py-4 text-center" }, /* @__PURE__ */ React.createElement("p", { className: "text-xs text-slate-500 leading-relaxed" }, "Posts are ranked by likes, comments & engagement over the last 7 days."))))))); } -const __vite_glob_0_52 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_62 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: TrendingFeed }, Symbol.toStringTag, { value: "Module" })); @@ -78973,7 +81844,7 @@ function ForumCategory({ category, parentCategory = null, threads = [], paginati "New topic" ))), /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 backdrop-blur" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-4 border-b border-white/[0.06] px-5 py-3" }, /* @__PURE__ */ React.createElement("span", { className: "flex-1 text-xs font-semibold uppercase tracking-widest text-white/30" }, "Topics"), /* @__PURE__ */ React.createElement("span", { className: "w-16 text-center text-xs font-semibold uppercase tracking-widest text-white/30" }, "Replies")), threads.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "px-5 py-12 text-center" }, /* @__PURE__ */ React.createElement("svg", { className: "mx-auto mb-4 text-zinc-600", width: "40", height: "40", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }, /* @__PURE__ */ React.createElement("path", { d: "M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" })), /* @__PURE__ */ React.createElement("p", { className: "text-sm text-zinc-500" }, "No topics in this board yet."), isAuthenticated && slug && /* @__PURE__ */ React.createElement("a", { href: `/forum/${slug}/new`, className: "mt-3 inline-block text-sm text-sky-300 hover:text-sky-200" }, "Be the first to start a discussion →")) : /* @__PURE__ */ React.createElement("div", null, threads.map((thread, i) => /* @__PURE__ */ React.createElement(ThreadRow, { key: thread.topic_id ?? thread.id ?? i, thread, isFirst: i === 0 })))), pagination?.last_page > 1 && /* @__PURE__ */ React.createElement("div", { className: "mt-6" }, /* @__PURE__ */ React.createElement(Pagination$1, { meta: pagination })))); } -const __vite_glob_0_53 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_63 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: ForumCategory }, Symbol.toStringTag, { value: "Module" })); @@ -79237,7 +82108,7 @@ function ForumEditPost({ post: post2, thread, csrfToken: csrfToken2, errors = {} ), /* @__PURE__ */ React.createElement(Button$1, { type: "submit", variant: "primary", size: "md", loading: submitting }, "Save changes")) ))); } -const __vite_glob_0_54 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_64 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: ForumEditPost }, Symbol.toStringTag, { value: "Module" })); @@ -79328,7 +82199,7 @@ function formatLastActivity(value) { } return `Updated ${date.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })}`; } -const __vite_glob_0_55 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_65 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: ForumIndex }, Symbol.toStringTag, { value: "Module" })); @@ -79449,7 +82320,7 @@ function ForumNewThread({ category, csrfToken: csrfToken2, errors = {}, oldValue /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between pt-2" }, /* @__PURE__ */ React.createElement("a", { href: `/forum/${slug}`, className: "text-sm text-zinc-500 hover:text-zinc-300 transition-colors" }, "← Cancel"), /* @__PURE__ */ React.createElement(Button$1, { type: "submit", variant: "primary", size: "md", loading: submitting }, "Publish topic")) ))); } -const __vite_glob_0_56 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_66 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: ForumNewThread }, Symbol.toStringTag, { value: "Module" })); @@ -79464,7 +82335,7 @@ function ForumSection({ category, boards = [], seo = {} }) { ]; return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(SeoHead, { seo }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-6xl px-4 pb-20 pt-10 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(Breadcrumbs, { items: breadcrumbs }), /* @__PURE__ */ React.createElement("section", { className: "mt-5 overflow-hidden rounded-3xl border border-white/10 bg-nova-800/55 shadow-xl backdrop-blur" }, /* @__PURE__ */ React.createElement("div", { className: "relative h-56 overflow-hidden sm:h-64" }, /* @__PURE__ */ React.createElement("img", { src: preview, alt: `${name2} preview`, className: "h-full w-full object-cover object-center" }), /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 bg-gradient-to-t from-black/85 via-black/35 to-transparent" }), /* @__PURE__ */ React.createElement("div", { className: "absolute inset-x-0 bottom-0 p-6 sm:p-8" }, /* @__PURE__ */ React.createElement("p", { className: "text-xs font-semibold uppercase tracking-[0.18em] text-cyan-200/85" }, "Forum Section"), /* @__PURE__ */ React.createElement("h1", { className: "mt-2 text-3xl font-black text-white sm:text-4xl" }, name2), description && /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-3xl text-sm text-white/70 sm:text-base" }, description)))), /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-2xl border border-white/8 bg-nova-800/45 p-5 backdrop-blur sm:p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-end justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-xs font-semibold uppercase tracking-[0.16em] text-white/40" }, "Subcategories"), /* @__PURE__ */ React.createElement("h2", { className: "mt-1 text-2xl font-bold text-white" }, "Browse boards")), /* @__PURE__ */ React.createElement("p", { className: "text-xs text-white/45 sm:text-sm" }, "Select a board to open its thread list.")), boards.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "py-12 text-center text-sm text-white/45" }, "No boards are available in this section yet.") : /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-4 md:grid-cols-2" }, boards.map((board) => /* @__PURE__ */ React.createElement("a", { key: board.id ?? board.slug, href: `/forum/${board.slug}`, className: "rounded-2xl border border-white/8 bg-white/[0.02] p-5 transition hover:border-cyan-400/25 hover:bg-white/[0.04] block" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h3", { className: "text-lg font-semibold text-white" }, board.title), board.description && /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-white/55" }, board.description)), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-cyan-300/20 bg-cyan-300/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-cyan-200" }, "Open")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-4 text-xs text-white/50" }, /* @__PURE__ */ React.createElement("span", null, board.topics_count ?? 0, " topics"), /* @__PURE__ */ React.createElement("span", null, board.posts_count ?? 0, " posts"), board.latest_topic?.title && /* @__PURE__ */ React.createElement("span", null, "Latest: ", board.latest_topic.title)))))))); } -const __vite_glob_0_57 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_67 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: ForumSection }, Symbol.toStringTag, { value: "Module" })); @@ -79802,7 +82673,7 @@ function formatDate$b(dateStr) { return ""; } } -const __vite_glob_0_58 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_68 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: ForumThread }, Symbol.toStringTag, { value: "Module" })); @@ -80046,7 +82917,7 @@ function GroupChallengeShow() { const outcomeSections = challenge.outcome_sections || {}; return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(234,179,8,0.15),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { seo: props.seo || {}, title: `${challenge.title || group.name} - Skinbase`, description: challenge.summary || challenge.description || "Group challenge" }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-6xl space-y-8" }, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[32px] border border-white/10 bg-white/[0.03]" }, challenge.cover_url ? /* @__PURE__ */ React.createElement("img", { src: challenge.cover_url, alt: challenge.title, className: "h-56 w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "h-40 bg-white/[0.03]" }), /* @__PURE__ */ React.createElement("div", { className: "p-6" }, /* @__PURE__ */ React.createElement("a", { href: group.urls?.public, className: "text-sm font-semibold text-amber-200" }, group.name), /* @__PURE__ */ React.createElement("h1", { className: "mt-4 text-4xl font-semibold text-white" }, challenge.title), /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-3xl text-sm leading-7 text-slate-300" }, challenge.summary || challenge.description || "Group challenge"), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-3 text-xs uppercase tracking-[0.16em] text-slate-400" }, /* @__PURE__ */ React.createElement("span", null, challenge.status), /* @__PURE__ */ React.createElement("span", null, challenge.visibility), /* @__PURE__ */ React.createElement("span", null, String(challenge.participation_scope || "").replace("_", " ")), challenge.start_at ? /* @__PURE__ */ React.createElement("span", null, "Starts ", new Date(challenge.start_at).toLocaleDateString()) : null, challenge.end_at ? /* @__PURE__ */ React.createElement("span", null, "Ends ", new Date(challenge.end_at).toLocaleDateString()) : null), /* @__PURE__ */ React.createElement(ChallengeWorldLinkBadge, { world: linkedWorld, className: "mt-5" }))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-8 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold text-white" }, "Challenge brief"), /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm leading-7 text-slate-300" }, challenge.description || "No extended challenge brief yet."), challenge.rules_text ? /* @__PURE__ */ React.createElement("div", { className: "mt-6 rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Rules"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-7 text-slate-300" }, challenge.rules_text)) : null, challenge.submission_instructions ? /* @__PURE__ */ React.createElement("div", { className: "mt-6 rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Submission instructions"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-7 text-slate-300" }, challenge.submission_instructions)) : null), /* @__PURE__ */ React.createElement("div", { className: "space-y-8" }, /* @__PURE__ */ React.createElement(OutcomeSection, { section: outcomeSections.winner }), /* @__PURE__ */ React.createElement(OutcomeSection, { section: outcomeSections.finalist }), /* @__PURE__ */ React.createElement(OutcomeSection, { section: outcomeSections.runner_up }), /* @__PURE__ */ React.createElement(OutcomeSection, { section: outcomeSections.honorable_mention }), /* @__PURE__ */ React.createElement(OutcomeSection, { section: outcomeSections.featured }), /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold text-white" }, "Entries"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-1" }, Array.isArray(challenge.artworks) && challenge.artworks.length > 0 ? challenge.artworks.map((artwork) => /* @__PURE__ */ React.createElement("a", { key: artwork.id, href: artwork.url, className: "overflow-hidden rounded-[24px] border border-white/10 bg-black/20" }, artwork.thumb ? /* @__PURE__ */ React.createElement("img", { src: artwork.thumb, alt: artwork.title, className: "aspect-[4/3] w-full object-cover" }) : null, /* @__PURE__ */ React.createElement("div", { className: "p-4 text-white" }, artwork.title))) : /* @__PURE__ */ React.createElement("p", { className: "text-sm text-slate-400" }, "No entries linked yet."))))))); } -const __vite_glob_0_59 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_69 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: GroupChallengeShow }, Symbol.toStringTag, { value: "Module" })); @@ -80056,7 +82927,7 @@ function GroupEventShow() { const event = props.event || {}; return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.15),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { seo: props.seo || {}, title: `${event.title || group.name} - Skinbase`, description: event.summary || event.description || "Group event" }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-5xl space-y-8" }, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[32px] border border-white/10 bg-white/[0.03]" }, event.cover_url ? /* @__PURE__ */ React.createElement("img", { src: event.cover_url, alt: event.title, className: "h-56 w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "h-40 bg-white/[0.03]" }), /* @__PURE__ */ React.createElement("div", { className: "p-6" }, /* @__PURE__ */ React.createElement("a", { href: group.urls?.public, className: "text-sm font-semibold text-emerald-200" }, group.name), /* @__PURE__ */ React.createElement("h1", { className: "mt-4 text-4xl font-semibold text-white" }, event.title), /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-3xl text-sm leading-7 text-slate-300" }, event.summary || event.description || "Group event"), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-3 sm:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Starts"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-white" }, event.start_at ? new Date(event.start_at).toLocaleString() : "Not scheduled")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Details"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-white" }, event.event_type, " • ", event.visibility), event.location ? /* @__PURE__ */ React.createElement("div", { className: "mt-2" }, event.location) : null)), event.external_url ? /* @__PURE__ */ React.createElement("a", { href: event.external_url, className: "mt-5 inline-flex rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Open external link") : null)), /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold text-white" }, "About this event"), /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm leading-7 text-slate-300" }, event.description || "No extended event details yet.")))); } -const __vite_glob_0_60 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_70 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: GroupEventShow }, Symbol.toStringTag, { value: "Module" })); @@ -80746,7 +83617,7 @@ function GroupFaqPage() { /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("a", { href: links.contact_support, className: "rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:bg-white/[0.05]" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Contact"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, "Contact support"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-slate-300" }, "Use this if your question is not answered here or if you need help with an account or workflow issue.")), /* @__PURE__ */ React.createElement("a", { href: links.report_issue, className: "rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:bg-white/[0.05]" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Report"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, "Report a problem"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-slate-300" }, "Use this if a route, role, contributor record, or Group workflow appears broken rather than just unclear."))) )), /* @__PURE__ */ React.createElement("aside", { className: "hidden xl:block xl:sticky xl:top-24 xl:self-start" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80" }, "Support flow"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-2" }, /* @__PURE__ */ React.createElement("a", { href: links.quickstart, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Open Quickstart"), /* @__PURE__ */ React.createElement("a", { href: links.full_documentation, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Read full documentation"), /* @__PURE__ */ React.createElement("a", { href: links.group_studio, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Open Group Studio"), /* @__PURE__ */ React.createElement("a", { href: links.create_group, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Create a Group"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-amber-300/20 bg-amber-400/10 p-4 text-amber-50" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80" }, "Quick troubleshooting rule"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-amber-50/85" }, "If something feels wrong, check three things first: are you in the right Group context, do you have the right role, and is the content public or internal?"))))))); } -const __vite_glob_0_61 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_71 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: GroupFaqPage }, Symbol.toStringTag, { value: "Module" })); @@ -81294,7 +84165,7 @@ function GroupHelpPage() { /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement("a", { href: links.create_group, className: "rounded-[28px] border border-sky-300/20 bg-sky-300/10 p-5 transition hover:border-sky-300/35 hover:bg-sky-300/15" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80" }, "Create"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, "Create your first Group"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-sky-50/80" }, "Start with branding, visibility, and your first member invites.")), /* @__PURE__ */ React.createElement("a", { href: links.group_studio, className: "rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:bg-white/[0.05]" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Manage"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, "Open Group Studio"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-slate-300" }, "Check members, workflow, releases, recruitment, and review status.")), /* @__PURE__ */ React.createElement("a", { href: links.contact_support, className: "rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:bg-white/[0.05]" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Contact"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, "Contact support"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-slate-300" }, "Use the general support flow if you need help untangling an account or workflow issue.")), /* @__PURE__ */ React.createElement("a", { href: links.report_issue, className: "rounded-[28px] border border-white/10 bg-black/20 p-5 transition hover:border-white/20 hover:bg-white/[0.05]" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Report"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, "Report a problem"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-slate-300" }, "Use this if a route, permission, credit record, or workflow appears broken."))) )), /* @__PURE__ */ React.createElement("aside", { className: "hidden xl:block xl:sticky xl:top-24 xl:self-start" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80" }, "Quick actions"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-2" }, /* @__PURE__ */ React.createElement("a", { href: links.groups_directory, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Browse public Groups"), /* @__PURE__ */ React.createElement("a", { href: links.group_studio, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Open Group Studio"), links.faq ? /* @__PURE__ */ React.createElement("a", { href: links.faq, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Open Groups FAQ") : null, /* @__PURE__ */ React.createElement("a", { href: "#publishing-as-a-group", className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Review publishing guidance"), /* @__PURE__ */ React.createElement("a", { href: "#contributor-credit", className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Check contributor credit rules"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-amber-300/20 bg-amber-400/10 p-4 text-amber-50" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80" }, "Read this before launch day"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-amber-50/85" }, "Before the first public release or artwork, confirm the Group context, contributor credit, and review expectations. Those three checks prevent most avoidable confusion."))))))); } -const __vite_glob_0_62 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_72 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: GroupHelpPage }, Symbol.toStringTag, { value: "Module" })); @@ -81369,7 +84240,7 @@ function GroupIndex() { } )), leaderboardItems.length > 0 ? /* @__PURE__ */ React.createElement("section", { className: "mt-10" }, /* @__PURE__ */ React.createElement("div", { className: "mb-5 flex items-end justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold tracking-[-0.02em] text-white" }, "Monthly group leaderboard"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-3xl text-sm leading-6 text-slate-400" }, "A fast view of the collaborative teams moving the most attention and publishing energy right now.")), /* @__PURE__ */ React.createElement("a", { href: "/leaderboard?type=groups&period=monthly", className: "text-sm font-semibold text-sky-200 transition hover:text-white" }, "View leaderboard")), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 xl:grid-cols-3" }, leaderboardItems.slice(0, 3).map((item) => /* @__PURE__ */ React.createElement(GroupLeaderboardCard, { key: item.entity?.id || item.rank, item })))) : null, /* @__PURE__ */ React.createElement("section", { className: "mt-10" }, /* @__PURE__ */ React.createElement("div", { className: "mb-5 flex items-end justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold tracking-[-0.02em] text-white" }, "Browse groups"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-3xl text-sm leading-6 text-slate-400" }, "Filter the directory by discovery surface, then jump into each group’s public page for artworks, releases, projects, events, and activity.")), /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-500" }, Number(props.groups?.meta?.total || 0).toLocaleString(), " public groups")), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2 xl:grid-cols-3" }, groups.map((group) => /* @__PURE__ */ React.createElement(GroupDiscoveryCard, { key: group.slug || group.id, group })))))); } -const __vite_glob_0_63 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_73 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: GroupIndex }, Symbol.toStringTag, { value: "Module" })); @@ -81399,7 +84270,7 @@ function GroupPostShow() { }; return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { seo: props.seo || {}, title: `${post2.title || group.name} - Skinbase`, description: post2.excerpt || group.headline || group.bio || "Group post" }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-5xl" }, /* @__PURE__ */ React.createElement("article", { className: "rounded-[32px] border border-white/10 bg-white/[0.03] p-6 sm:p-8" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("a", { href: group.urls?.public, className: "text-sm font-semibold text-sky-200" }, "← Back to ", group.name), props.reportEndpoint ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: submitReport, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white" }, "Report") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2 text-xs uppercase tracking-[0.16em] text-slate-400" }, post2.type ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1" }, post2.type) : null, post2.is_pinned ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-amber-100" }, "Pinned") : null), /* @__PURE__ */ React.createElement("h1", { className: "mt-5 text-4xl font-semibold text-white" }, post2.title), /* @__PURE__ */ React.createElement("div", { className: "mt-3 text-sm text-slate-400" }, post2.author?.name || post2.author?.username || group.name, " • ", post2.published_at ? new Date(post2.published_at).toLocaleString() : "Recently"), post2.excerpt ? /* @__PURE__ */ React.createElement("p", { className: "mt-6 text-lg leading-8 text-slate-200" }, post2.excerpt) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-8 whitespace-pre-wrap text-sm leading-7 text-slate-300" }, post2.content || "")), recentPosts.length > 0 ? /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[32px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold text-white" }, "More from ", group.name), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-4 md:grid-cols-2" }, recentPosts.filter((item) => item.id !== post2.id).map((item) => /* @__PURE__ */ React.createElement("a", { key: item.id, href: item.url, className: "rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, item.type), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-400" }, item.excerpt || "Read the full post."))))) : null)); } -const __vite_glob_0_64 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_74 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: GroupPostShow }, Symbol.toStringTag, { value: "Module" })); @@ -81415,7 +84286,7 @@ function GroupProjectShow() { const project = props.project || {}; return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { seo: props.seo || {}, title: `${project.title || group.name} - Skinbase`, description: project.summary || project.description || group.headline || "Group project" }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-6xl space-y-8" }, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[32px] border border-white/10 bg-white/[0.03]" }, project.cover_url ? /* @__PURE__ */ React.createElement("img", { src: project.cover_url, alt: project.title, className: "h-56 w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "h-40 bg-white/[0.03]" }), /* @__PURE__ */ React.createElement("div", { className: "p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-3" }, /* @__PURE__ */ React.createElement("a", { href: group.urls?.public, className: "text-sm font-semibold text-sky-200" }, group.name), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, project.status), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, project.visibility)), /* @__PURE__ */ React.createElement("h1", { className: "mt-4 text-4xl font-semibold text-white" }, project.title), project.summary ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-3xl text-sm leading-7 text-slate-300" }, project.summary) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-4 text-xs text-slate-400" }, project.start_date ? /* @__PURE__ */ React.createElement("span", null, "Started ", new Date(project.start_date).toLocaleDateString()) : null, project.target_date ? /* @__PURE__ */ React.createElement("span", null, "Target ", new Date(project.target_date).toLocaleDateString()) : null, project.released_at ? /* @__PURE__ */ React.createElement("span", null, "Released ", new Date(project.released_at).toLocaleDateString()) : null, project.lead?.name || project.lead?.username ? /* @__PURE__ */ React.createElement("span", null, "Lead: ", project.lead?.name || project.lead?.username) : null))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-8 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold text-white" }, "Overview"), /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm leading-7 text-slate-300" }, project.description || "No long-form description yet."), Array.isArray(project.milestones) && project.milestones.length > 0 ? /* @__PURE__ */ React.createElement("div", { className: "mt-6 space-y-3" }, project.milestones.map((milestone) => /* @__PURE__ */ React.createElement("div", { key: milestone.id, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "font-semibold text-white" }, milestone.title), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, milestone.status)), milestone.summary ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-400" }, milestone.summary) : null, milestone.owner?.name || milestone.owner?.username ? /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-xs text-slate-500" }, "Owner: ", milestone.owner?.name || milestone.owner?.username) : null))) : null, /* @__PURE__ */ React.createElement(ArtworkGrid$2, { artworks: project.artworks })), /* @__PURE__ */ React.createElement("div", { className: "space-y-8" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold text-white" }, "Pipeline"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 text-sm leading-7 text-slate-300" }, "This project currently has ", project.counts?.milestones || 0, " milestones and is linked to ", project.release_count || project.counts?.releases || 0, " releases.")), Array.isArray(project.assets) && project.assets.length > 0 ? /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold text-white" }, "Assets"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, project.assets.map((asset) => /* @__PURE__ */ React.createElement("a", { key: asset.id, href: asset.download_url, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement("div", { className: "font-semibold" }, asset.title), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs uppercase tracking-[0.16em] text-slate-400" }, asset.category, " • ", asset.visibility))))) : null, Array.isArray(project.team) && project.team.length > 0 ? /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold text-white" }, "Team"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, project.team.map((member) => /* @__PURE__ */ React.createElement("div", { key: member.id, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement("div", { className: "font-semibold" }, member.name || member.username), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs uppercase tracking-[0.16em] text-slate-400" }, member.role_label || (member.is_lead ? "Lead" : "Contributor")))))) : null, project.pinned_post ? /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold text-white" }, "Pinned update"), /* @__PURE__ */ React.createElement("a", { href: project.pinned_post.url, className: "mt-4 inline-block text-sm font-semibold text-sky-200" }, project.pinned_post.title)) : null)))); } -const __vite_glob_0_65 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_75 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: GroupProjectShow }, Symbol.toStringTag, { value: "Module" })); @@ -81714,7 +84585,7 @@ function GroupQuickstartPage() { /* @__PURE__ */ React.createElement(QuickstartNextSteps, { items: nextSteps }) ))))); } -const __vite_glob_0_66 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_76 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: GroupQuickstartPage }, Symbol.toStringTag, { value: "Module" })); @@ -81732,7 +84603,7 @@ function GroupReleaseShow() { const milestones = Array.isArray(release.milestones) ? release.milestones : []; return /* @__PURE__ */ React.createElement("main", { className: "min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { seo: props.seo || {}, title: `${release.title || group.name} - Skinbase`, description: release.summary || release.description || group.headline || "Group release" }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-6xl space-y-8" }, /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[32px] border border-white/10 bg-white/[0.03]" }, release.cover_url ? /* @__PURE__ */ React.createElement("img", { src: release.cover_url, alt: release.title, className: "h-64 w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "h-44 bg-white/[0.03]" }), /* @__PURE__ */ React.createElement("div", { className: "p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-3" }, /* @__PURE__ */ React.createElement("a", { href: group.urls?.public, className: "text-sm font-semibold text-sky-200" }, group.name), release.status ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, release.status) : null, release.current_stage ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, release.current_stage) : null), /* @__PURE__ */ React.createElement("h1", { className: "mt-4 text-4xl font-semibold text-white" }, release.title), release.summary ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-3xl text-sm leading-7 text-slate-300" }, release.summary) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-4 text-xs text-slate-400" }, release.released_at ? /* @__PURE__ */ React.createElement("span", null, "Released ", new Date(release.released_at).toLocaleDateString()) : null, release.planned_release_at ? /* @__PURE__ */ React.createElement("span", null, "Planned ", new Date(release.planned_release_at).toLocaleDateString()) : null, release.lead?.name || release.lead?.username ? /* @__PURE__ */ React.createElement("span", null, "Lead: ", release.lead?.name || release.lead?.username) : null, /* @__PURE__ */ React.createElement("span", null, release.counts?.artworks || 0, " artworks"), /* @__PURE__ */ React.createElement("span", null, release.counts?.contributors || 0, " contributors"), /* @__PURE__ */ React.createElement("span", null, release.counts?.milestones || 0, " milestones")))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-8 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold text-white" }, "Overview"), /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm leading-7 text-slate-300" }, release.description || "No long-form release description yet."), release.release_notes ? /* @__PURE__ */ React.createElement("div", { className: "mt-6 rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Release notes"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-300" }, release.release_notes)) : null, /* @__PURE__ */ React.createElement(ArtworkGrid$1, { artworks: release.artworks })), /* @__PURE__ */ React.createElement("div", { className: "space-y-8" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold text-white" }, "Links"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, release.linked_project?.url ? /* @__PURE__ */ React.createElement("a", { href: release.linked_project.url, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement("div", { className: "font-semibold" }, release.linked_project.title), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs uppercase tracking-[0.16em] text-slate-400" }, "Linked project")) : null, release.linked_collection?.url ? /* @__PURE__ */ React.createElement("a", { href: release.linked_collection.url, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement("div", { className: "font-semibold" }, release.linked_collection.title), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs uppercase tracking-[0.16em] text-slate-400" }, "Linked collection")) : null, release.featured_artwork ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement("div", { className: "font-semibold" }, release.featured_artwork.title), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs uppercase tracking-[0.16em] text-slate-400" }, "Featured artwork")) : null)), /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold text-white" }, "Contributors"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, contributors.length > 0 ? contributors.map((contributor) => /* @__PURE__ */ React.createElement("div", { key: contributor.id, className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, contributor.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: contributor.avatar_url, alt: contributor.name || contributor.username, className: "h-11 w-11 rounded-2xl object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-user" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0" }, /* @__PURE__ */ React.createElement("div", { className: "truncate font-semibold text-white" }, contributor.name || contributor.username), /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.16em] text-slate-400" }, contributor.role_label || "Contributor")))) : /* @__PURE__ */ React.createElement("p", { className: "text-sm text-slate-400" }, "No contributor credits yet."))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold text-white" }, "Milestones"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, milestones.length > 0 ? milestones.map((milestone) => /* @__PURE__ */ React.createElement("div", { key: milestone.id, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "font-semibold text-white" }, milestone.title), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, milestone.status)), milestone.summary ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-400" }, milestone.summary) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-xs text-slate-500" }, milestone.owner?.name || milestone.owner?.username || "No owner", milestone.due_date ? ` • due ${milestone.due_date}` : ""))) : /* @__PURE__ */ React.createElement("p", { className: "text-sm text-slate-400" }, "No milestones defined yet."))))))); } -const __vite_glob_0_67 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_77 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: GroupReleaseShow }, Symbol.toStringTag, { value: "Module" })); @@ -82160,7 +85031,7 @@ function GroupShow() { return /* @__PURE__ */ React.createElement("section", { key: label, className: "relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("div", { className: `absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r ${roleKey === "owner" ? "from-amber-400/70 to-transparent" : roleKey === "admin" ? "from-sky-400/70 to-transparent" : roleKey === "editor" ? "from-violet-400/70 to-transparent" : "from-emerald-400/70 to-transparent"}` }), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3" }, /* @__PURE__ */ React.createElement("span", { className: `inline-flex h-8 w-8 items-center justify-center rounded-xl border ${roleStyle.badge}` }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${roleStyle.icon} fa-fw text-sm ${roleStyle.iconColor}` })), /* @__PURE__ */ React.createElement("h3", { className: "text-lg font-semibold text-white" }, label), /* @__PURE__ */ React.createElement("span", { className: "ml-auto rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300" }, bucket.length)), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3" }, bucket.map((member) => /* @__PURE__ */ React.createElement("a", { key: member.id, href: member.user?.profile_url || "#", className: "group flex items-center gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4 transition hover:border-white/20 hover:bg-white/[0.04]" }, member.user?.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: member.user.avatar_url, alt: member.user.name || member.user.username, className: "h-12 w-12 shrink-0 rounded-2xl object-cover ring-1 ring-white/10 transition group-hover:ring-white/20" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-user" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0" }, /* @__PURE__ */ React.createElement("div", { className: "truncate font-semibold text-white" }, member.user?.name || member.user?.username), /* @__PURE__ */ React.createElement("div", { className: `mt-1 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] ${roleStyle.badge}` }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${roleStyle.icon} fa-fw text-[9px]` }), member.role_label || member.role)))))); }))) : null, section === "about" ? /* @__PURE__ */ React.createElement("section", { className: "mt-8 relative overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("div", { className: "absolute inset-x-0 top-0 h-[3px] rounded-t-[30px] bg-gradient-to-r from-slate-400/50 to-transparent" }), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3" }, /* @__PURE__ */ React.createElement("span", { className: "inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-white/10 bg-white/[0.05] text-slate-300" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-id-card fa-fw" })), /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold text-white" }, "About")), /* @__PURE__ */ React.createElement("div", { className: "mt-5 space-y-4 text-sm leading-7 text-slate-300" }, /* @__PURE__ */ React.createElement("p", null, group.bio || "No long-form description yet."), group.website_url ? /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("a", { href: group.website_url, className: "inline-flex items-center gap-1.5 text-sky-200 underline underline-offset-4 transition hover:text-sky-100" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-link" }), group.website_url)) : null, Array.isArray(group.links) && group.links.length > 0 ? /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, group.links.map((link2) => /* @__PURE__ */ React.createElement("a", { key: `${link2.label}-${link2.url}`, href: link2.url, className: "inline-flex items-center gap-2 rounded-[14px] border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-up-right-from-square text-slate-400" }), link2.label))) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-4 border-t border-white/8 pt-4 text-xs text-slate-400" }, group.founded_at ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center gap-2" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-calendar-days text-slate-500" }), "Founded ", new Date(group.founded_at).toLocaleDateString()) : null, group.type ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center gap-2" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-tag text-slate-500" }), group.type) : null))) : null)); } -const __vite_glob_0_68 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_78 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: GroupShow }, Symbol.toStringTag, { value: "Module" })); @@ -82418,7 +85289,7 @@ function AccountHelpPage() { /* @__PURE__ */ React.createElement(QuickstartNextSteps, { items: relatedHelpItems }) )), /* @__PURE__ */ React.createElement("aside", { className: "hidden xl:block xl:sticky xl:top-24 xl:self-start" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-200/80" }, "Quick route map"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-2" }, /* @__PURE__ */ React.createElement("a", { href: signedIn ? links.profile_settings : links.login, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, signedIn ? "Open account settings" : "Open login"), /* @__PURE__ */ React.createElement("a", { href: links.help_auth, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Read auth help"), /* @__PURE__ */ React.createElement("a", { href: links.help_profile, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Read Profile help"), /* @__PURE__ */ React.createElement("a", { href: links.help_troubleshooting, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Open troubleshooting"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-emerald-300/20 bg-emerald-400/10 p-4 text-emerald-50" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-100/80" }, "Fast reminder"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-emerald-50/85" }, "The healthiest account is the one with a current email, a manageable password, and settings reviewed before they become emergency work."))))))); } -const __vite_glob_0_69 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_79 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AccountHelpPage }, Symbol.toStringTag, { value: "Module" })); @@ -82774,7 +85645,7 @@ function AuthHelpPage() { /* @__PURE__ */ React.createElement(QuickstartNextSteps, { items: relatedHelpItems }) )), /* @__PURE__ */ React.createElement("aside", { className: "hidden xl:block xl:sticky xl:top-24 xl:self-start" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80" }, "Quick route map"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-2" }, /* @__PURE__ */ React.createElement("a", { href: links.login, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Open login"), /* @__PURE__ */ React.createElement("a", { href: links.register, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Create account"), /* @__PURE__ */ React.createElement("a", { href: links.password_request, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Reset password"), /* @__PURE__ */ React.createElement("a", { href: links.help_account, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Read account settings help"), /* @__PURE__ */ React.createElement("a", { href: links.help_troubleshooting, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Open troubleshooting hub"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-amber-300/20 bg-amber-400/10 p-4 text-amber-50" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80" }, "Fast reminder"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-amber-50/85" }, "If access breaks, check four things first: the email, the password, the inbox, and whether the problem is really permissions rather than login."))))))); } -const __vite_glob_0_70 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_80 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: AuthHelpPage }, Symbol.toStringTag, { value: "Module" })); @@ -83200,7 +86071,7 @@ function CardsHelpPage() { /* @__PURE__ */ React.createElement(QuickstartNextSteps, { items: relatedHelpItems }) )), /* @__PURE__ */ React.createElement("aside", { className: "hidden xl:block xl:sticky xl:top-24 xl:self-start" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80" }, "Quick route map"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-2" }, /* @__PURE__ */ React.createElement("a", { href: links.create_card, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Create a Card"), /* @__PURE__ */ React.createElement("a", { href: links.studio_cards, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Open Cards workspace"), /* @__PURE__ */ React.createElement("a", { href: links.cards_index, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Browse public Cards"), /* @__PURE__ */ React.createElement("a", { href: links.studio_help, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Read Studio help"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-amber-300/20 bg-amber-400/10 p-4 text-amber-50" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80" }, "Fast reminder"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-amber-50/85" }, "If the content feels unclear, ask one question first: is this a Card, an artwork, a post, or a collection? The answer usually fixes the workflow too."))))))); } -const __vite_glob_0_71 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_81 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: CardsHelpPage }, Symbol.toStringTag, { value: "Module" })); @@ -84109,7 +86980,7 @@ function HelpCenterPage() { /* @__PURE__ */ React.createElement(HelpSupportCta, { items: supportItems }) )), /* @__PURE__ */ React.createElement("aside", { className: "hidden xl:block xl:sticky xl:top-24 xl:self-start" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80" }, "Help architecture"), /* @__PURE__ */ React.createElement("ul", { className: "mt-4 space-y-3 text-sm leading-6 text-slate-300" }, /* @__PURE__ */ React.createElement("li", null, "Use ", /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, "/help"), " as the main hub."), /* @__PURE__ */ React.createElement("li", null, "Use ", /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, "/help/topic"), " for overview pages."), /* @__PURE__ */ React.createElement("li", null, "Use ", /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, "/help/topic/subpage"), " for quickstarts, FAQs, and troubleshooting."))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Current coverage"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-300" }, "Groups is the first complete multi-page topic family, and Studio, Upload, Cards, Profile, Signup / Login, Account Settings, and Troubleshooting are now live topic guides. The rest of the Help Center still follows the same predictable expansion path."))))))); } -const __vite_glob_0_72 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_82 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: HelpCenterPage }, Symbol.toStringTag, { value: "Module" })); @@ -84498,7 +87369,7 @@ function ProfileHelpPage() { /* @__PURE__ */ React.createElement(QuickstartNextSteps, { items: relatedHelpItems }) )), /* @__PURE__ */ React.createElement("aside", { className: "hidden xl:block xl:sticky xl:top-24 xl:self-start" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80" }, "Quick route map"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-2" }, /* @__PURE__ */ React.createElement("a", { href: links.profile_settings, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Open profile settings"), /* @__PURE__ */ React.createElement("a", { href: links.groups_help, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Read Groups help"), /* @__PURE__ */ React.createElement("a", { href: links.studio_help, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Read Studio help"), /* @__PURE__ */ React.createElement("a", { href: links.upload_help, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Read Upload help"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-amber-300/20 bg-amber-400/10 p-4 text-amber-50" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80" }, "Fast reminder"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-amber-50/85" }, "A better profile usually starts with three things: a recognizable avatar, a clearer bio, and a stronger sense of what you want people to remember about you."))))))); } -const __vite_glob_0_73 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_83 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: ProfileHelpPage }, Symbol.toStringTag, { value: "Module" })); @@ -84953,7 +87824,7 @@ function StudioHelpPage() { /* @__PURE__ */ React.createElement(QuickstartNextSteps, { items: relatedHelpItems }) )), /* @__PURE__ */ React.createElement("aside", { className: "hidden xl:block xl:sticky xl:top-24 xl:self-start" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80" }, "Quick route map"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-2" }, /* @__PURE__ */ React.createElement("a", { href: links.open_studio, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Open Studio"), /* @__PURE__ */ React.createElement("a", { href: links.studio_drafts, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Open drafts"), /* @__PURE__ */ React.createElement("a", { href: links.group_studio, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Open Group Studio"), /* @__PURE__ */ React.createElement("a", { href: links.groups_help, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Read Groups help"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-amber-300/20 bg-amber-400/10 p-4 text-amber-50" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80" }, "Fast reminder"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-amber-50/85" }, "If something feels missing, check context first. Personal Studio and Group Studio are connected, but they are not identical workspaces."))))))); } -const __vite_glob_0_74 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_84 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioHelpPage }, Symbol.toStringTag, { value: "Module" })); @@ -85195,7 +88066,7 @@ function TroubleshootingHelpPage() { /* @__PURE__ */ React.createElement(QuickstartNextSteps, { items: relatedHelpItems }) )), /* @__PURE__ */ React.createElement("aside", { className: "hidden xl:block xl:sticky xl:top-24 xl:self-start" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-200/80" }, "Quick route map"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-2" }, /* @__PURE__ */ React.createElement("a", { href: links.help_auth, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Read auth help"), /* @__PURE__ */ React.createElement("a", { href: links.help_account, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Read account settings help"), /* @__PURE__ */ React.createElement("a", { href: links.upload_help, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Read Upload help"), /* @__PURE__ */ React.createElement("a", { href: links.groups_faq, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Open Groups FAQ"), /* @__PURE__ */ React.createElement("a", { href: links.report_issue, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Report a problem"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-rose-300/20 bg-rose-400/10 p-4 text-rose-50" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-rose-100/80" }, "Fast reminder"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-rose-50/85" }, "A clear problem statement beats frantic guessing. Name the route, the context, and what changed before you decide the product is broken."))))))); } -const __vite_glob_0_75 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_85 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: TroubleshootingHelpPage }, Symbol.toStringTag, { value: "Module" })); @@ -85613,7 +88484,7 @@ function UploadHelpPage() { /* @__PURE__ */ React.createElement(QuickstartNextSteps, { items: relatedHelpItems }) )), /* @__PURE__ */ React.createElement("aside", { className: "hidden xl:block xl:sticky xl:top-24 xl:self-start" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80" }, "Quick route map"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-2" }, /* @__PURE__ */ React.createElement("a", { href: links.upload, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Start upload"), /* @__PURE__ */ React.createElement("a", { href: links.studio_drafts, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Open drafts"), /* @__PURE__ */ React.createElement("a", { href: links.studio_help, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Read Studio help"), /* @__PURE__ */ React.createElement("a", { href: links.groups_help, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Read Groups help"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-amber-300/20 bg-amber-400/10 p-4 text-amber-50" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80" }, "Fast reminder"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-amber-50/85" }, "If an upload feels wrong, check three things first: context, draft state, and contributor credit."))))))); } -const __vite_glob_0_76 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_86 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: UploadHelpPage }, Symbol.toStringTag, { value: "Module" })); @@ -86076,7 +88947,7 @@ function WorldsHelpPage() { /* @__PURE__ */ React.createElement(QuickstartNextSteps, { items: relatedHelpItems }) )), /* @__PURE__ */ React.createElement("aside", { className: "hidden xl:block xl:sticky xl:top-24 xl:self-start" }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4 rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.22)]" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80" }, "Quick route map"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-2" }, /* @__PURE__ */ React.createElement("a", { href: links.create_world, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Create a World"), /* @__PURE__ */ React.createElement("a", { href: links.studio_worlds, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Open Worlds workspace"), /* @__PURE__ */ React.createElement("a", { href: links.worlds_index, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Browse public Worlds"), /* @__PURE__ */ React.createElement("a", { href: links.studio_help, className: "block rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.05]" }, "Read Studio help"))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-sky-300/20 bg-sky-400/10 p-4 text-sky-50" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/80" }, "Fast reminder"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-sky-50/85" }, "A World should feel like an editorial decision, not a container. If the page feels cluttered, the usual fix is stronger curation, fewer modules, and clearer promotion intent."))))))); } -const __vite_glob_0_77 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_87 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: WorldsHelpPage }, Symbol.toStringTag, { value: "Module" })); @@ -86187,7 +89058,7 @@ function LeaderboardPage() { const items = Array.isArray(data?.items) ? data.items : []; return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(SeoHead, { seo, title: seo?.title || "Leaderboard — Skinbase", description: seo?.description || "Top creators, groups, artworks, stories, and Worlds on Skinbase." }), /* @__PURE__ */ React.createElement("div", { className: "min-h-screen bg-[radial-gradient(circle_at_top,rgba(14,165,233,0.14),transparent_34%),linear-gradient(180deg,#020617_0%,#0f172a_48%,#020617_100%)] pb-16 text-slate-100" }, /* @__PURE__ */ React.createElement("div", { className: "mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement("header", { className: "rounded-[2rem] border border-white/10 bg-slate-950/70 px-6 py-8 shadow-[0_35px_120px_rgba(2,6,23,0.75)] backdrop-blur" }, /* @__PURE__ */ React.createElement("p", { className: "text-xs font-semibold uppercase tracking-[0.28em] text-sky-300" }, "Skinbase Competition Board"), /* @__PURE__ */ React.createElement("h1", { className: "mt-4 max-w-3xl text-4xl font-black tracking-tight text-white sm:text-5xl" }, "Top creators, groups, standout artworks, stories, and Worlds with momentum."), /* @__PURE__ */ React.createElement("p", { className: "mt-4 max-w-2xl text-sm leading-6 text-slate-300 sm:text-base" }, "Switch between creators, groups, artworks, stories, and Worlds, then filter by daily, weekly, monthly, or all-time performance.")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 space-y-4" }, /* @__PURE__ */ React.createElement(LeaderboardTabs, { items: TYPE_TABS, active: type2, onChange: setType, sticky: true, label: "Leaderboard type" }), /* @__PURE__ */ React.createElement(LeaderboardTabs, { items: PERIOD_TABS, active: period, onChange: setPeriod, label: "Leaderboard period" })), loading ? /* @__PURE__ */ React.createElement("div", { className: "mt-6 rounded-3xl border border-white/10 bg-white/[0.03] px-6 py-5 text-sm text-slate-400" }, "Refreshing leaderboard...") : null, /* @__PURE__ */ React.createElement("div", { className: "mt-8" }, /* @__PURE__ */ React.createElement(LeaderboardList, { items, type: type2 }))))); } -const __vite_glob_0_78 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_88 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: LeaderboardPage }, Symbol.toStringTag, { value: "Module" })); @@ -88125,11 +90996,11 @@ if (typeof document !== "undefined") { ); } } -const __vite_glob_0_79 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_89 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: MessagesPage }, Symbol.toStringTag, { value: "Module" })); -function requestJson$9(url, { method = "GET", body: body2 } = {}) { +function requestJson$b(url, { method = "GET", body: body2 } = {}) { return fetch(url, { method, credentials: "same-origin", @@ -88186,7 +91057,7 @@ function ArtworkMaturityQueue() { ai_action: nextAiAction, ai_status: nextAiStatus }); - const payload = await requestJson$9(`${endpoints.list}?${query.toString()}`); + const payload = await requestJson$b(`${endpoints.list}?${query.toString()}`); setItems(payload.data || []); setStats(payload.meta?.stats || {}); } catch (loadError) { @@ -88197,7 +91068,7 @@ function ArtworkMaturityQueue() { setBusyId(itemId); setError(""); try { - const payload = await requestJson$9(String(endpoints.reviewPattern || "").replace("__ARTWORK__", String(itemId)), { + const payload = await requestJson$b(String(endpoints.reviewPattern || "").replace("__ARTWORK__", String(itemId)), { method: "POST", body: { action, @@ -88292,10 +91163,367 @@ function ArtworkMaturityQueue() { } )); } -const __vite_glob_0_81 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_91 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: ArtworkMaturityQueue }, Symbol.toStringTag, { value: "Module" })); +function getCsrfToken$5() { + if (typeof document === "undefined") return ""; + return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; +} +async function requestJson$a(url, { method = "POST", body: body2 } = {}) { + const response = await fetch(url, { + method, + credentials: "same-origin", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-CSRF-TOKEN": getCsrfToken$5(), + "X-Requested-With": "XMLHttpRequest" + }, + body: body2 ? JSON.stringify(body2) : void 0 + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload?.message || payload?.errors?.story?.[0] || "Request failed."); + } + return payload; +} +function replacePattern$1(pattern, value) { + return String(pattern || "").replace("__STORY__", String(value)).replace("__WORLD__", String(value)); +} +function StatusBadge({ story }) { + const tone = story.status === "published" ? "border-emerald-300/20 bg-emerald-400/12 text-emerald-100" : story.status === "archived" ? "border-amber-300/20 bg-amber-400/12 text-amber-100" : "border-white/10 bg-white/[0.06] text-slate-200"; + return /* @__PURE__ */ React.createElement("span", { className: `inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${tone}` }, story.status); +} +function WorldWebStoriesIndex() { + const { props } = X$1(); + const stories = props.stories || { data: [] }; + const endpoints = props.endpoints || {}; + const worldOptions = props.worldOptions || []; + const [filters, setFilters] = React.useState(props.filters || { q: "", status: "all" }); + const [notice, setNotice] = React.useState(""); + const [error, setError] = React.useState(""); + const [busyKey, setBusyKey] = React.useState(""); + const [generator, setGenerator] = React.useState({ world_id: worldOptions[0]?.value || "", pages: 7, force: false, publish: false }); + React.useEffect(() => { + setFilters(props.filters || { q: "", status: "all" }); + }, [props.filters]); + function applyFilters(event) { + event.preventDefault(); + At.get(endpoints.index, filters, { preserveState: true, replace: true, preserveScroll: true }); + } + async function performAction(key, url, method = "POST", body2 = null) { + setBusyKey(key); + setNotice(""); + setError(""); + try { + const payload = await requestJson$a(url, { method, body: body2 }); + setNotice(payload.message || "Action completed."); + At.reload({ only: ["stories", "stats", "filters"], preserveScroll: true }); + } catch (requestError) { + setError(requestError.message || "Action failed."); + } finally { + setBusyKey(""); + } + } + async function generateDraft(event) { + event.preventDefault(); + if (!generator.world_id) return; + setBusyKey("generate"); + setNotice(""); + setError(""); + try { + const payload = await requestJson$a(replacePattern$1(endpoints.generatePattern, generator.world_id), { + body: { + pages: Number(generator.pages || 7), + force: Boolean(generator.force), + publish: Boolean(generator.publish) + } + }); + setNotice(payload.message || "Web story generated."); + if (payload.story?.edit_url) { + At.visit(payload.story.edit_url); + return; + } + At.reload({ only: ["stories", "stats"], preserveScroll: true }); + } catch (requestError) { + setError(requestError.message || "Generation failed."); + } finally { + setBusyKey(""); + } + } + return /* @__PURE__ */ React.createElement("div", { className: "w-full pb-16 pt-8" }, /* @__PURE__ */ React.createElement(Se$1, { title: "World Web Stories" }), /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80" }, "Moderation surface"), /* @__PURE__ */ React.createElement("h1", { className: "mt-3 text-3xl font-semibold tracking-[-0.04em] text-white" }, "World Web Stories"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-3xl text-sm leading-relaxed text-slate-300" }, "Create standalone AMP Web Stories for Skinbase Worlds, keep them self-canonical, and publish only when the story is complete and visible.")), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement(xe, { href: endpoints.create, className: "inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-plus text-[10px]" }), "New story"))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4" }, [ + ["Total stories", props.stats?.total || 0], + ["Published", props.stats?.published || 0], + ["Drafts", props.stats?.draft || 0], + ["Hidden", props.stats?.hidden || 0] + ].map(([label, value]) => /* @__PURE__ */ React.createElement("div", { key: label, className: "rounded-[24px] border border-white/10 bg-white/[0.04] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400" }, label), /* @__PURE__ */ React.createElement("div", { className: "mt-4 text-3xl font-semibold tracking-[-0.04em] text-white" }, Number(value).toLocaleString())))), /* @__PURE__ */ React.createElement("form", { onSubmit: applyFilters, className: "mt-6 grid gap-3 lg:grid-cols-[2fr_1fr_auto]" }, /* @__PURE__ */ React.createElement( + "input", + { + value: filters.q || "", + onChange: (event) => setFilters((current) => ({ ...current, q: event.target.value })), + placeholder: "Search by title, slug, or world", + className: "rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none" + } + ), /* @__PURE__ */ React.createElement( + "select", + { + value: filters.status || "all", + onChange: (event) => setFilters((current) => ({ ...current, status: event.target.value })), + className: "rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none" + }, + /* @__PURE__ */ React.createElement("option", { value: "all" }, "All statuses"), + /* @__PURE__ */ React.createElement("option", { value: "draft" }, "Draft"), + /* @__PURE__ */ React.createElement("option", { value: "published" }, "Published"), + /* @__PURE__ */ React.createElement("option", { value: "archived" }, "Archived") + ), /* @__PURE__ */ React.createElement("button", { type: "submit", className: "rounded-2xl border border-white/10 bg-white/[0.06] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.1]" }, "Apply")), /* @__PURE__ */ React.createElement("form", { onSubmit: generateDraft, className: "mt-6 rounded-[24px] border border-white/10 bg-black/20 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400" }, "Generate from World"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 lg:grid-cols-[2fr_120px_auto_auto_auto]" }, /* @__PURE__ */ React.createElement( + "select", + { + value: generator.world_id, + onChange: (event) => setGenerator((current) => ({ ...current, world_id: event.target.value })), + className: "rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none" + }, + /* @__PURE__ */ React.createElement("option", { value: "" }, "Select a World"), + worldOptions.map((world) => /* @__PURE__ */ React.createElement("option", { key: world.value, value: world.value }, world.label)) + ), /* @__PURE__ */ React.createElement( + "input", + { + type: "number", + min: "5", + max: "10", + value: generator.pages, + onChange: (event) => setGenerator((current) => ({ ...current, pages: event.target.value })), + className: "rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none" + } + ), /* @__PURE__ */ React.createElement("label", { className: "flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("input", { type: "checkbox", checked: generator.force, onChange: (event) => setGenerator((current) => ({ ...current, force: event.target.checked })) }), "Force"), /* @__PURE__ */ React.createElement("label", { className: "flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("input", { type: "checkbox", checked: generator.publish, onChange: (event) => setGenerator((current) => ({ ...current, publish: event.target.checked })) }), "Publish"), /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: busyKey === "generate", className: "rounded-2xl border border-sky-300/20 bg-sky-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20 disabled:opacity-60" }, "Generate")))), notice ? /* @__PURE__ */ React.createElement("div", { className: "mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50" }, notice) : null, error ? /* @__PURE__ */ React.createElement("div", { className: "mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100" }, error) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-8 grid gap-4 xl:grid-cols-2" }, (stories.data || []).map((story) => /* @__PURE__ */ React.createElement("article", { key: story.id, className: "overflow-hidden rounded-[28px] border border-white/10 bg-[#08111d] shadow-[0_18px_48px_rgba(2,6,23,0.2)]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-[180px_1fr]" }, /* @__PURE__ */ React.createElement("div", { className: "aspect-[3/4] bg-black/30" }, story.poster_portrait_url ? /* @__PURE__ */ React.createElement("img", { src: story.poster_portrait_url, alt: story.title, className: "h-full w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-full items-center justify-center text-white/20" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-book-open-reader text-4xl" }))), /* @__PURE__ */ React.createElement("div", { className: "p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement(StatusBadge, { story }), !story.active ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex rounded-full border border-amber-300/20 bg-amber-400/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100" }, "inactive") : null, story.noindex ? /* @__PURE__ */ React.createElement("span", { className: "inline-flex rounded-full border border-rose-300/20 bg-rose-400/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-rose-100" }, "noindex") : null), /* @__PURE__ */ React.createElement("h2", { className: "mt-3 text-2xl font-semibold tracking-[-0.03em] text-white" }, story.title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-300" }, "/", story.slug, story.world ? ` • ${story.world.title}` : ""), story.excerpt ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-slate-300" }, story.excerpt) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2 text-xs uppercase tracking-[0.16em] text-slate-400" }, /* @__PURE__ */ React.createElement("span", null, story.page_count, " pages"), story.published_at ? /* @__PURE__ */ React.createElement("span", null, new Date(story.published_at).toLocaleDateString()) : null), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement(xe, { href: replacePattern$1(endpoints.editPattern, story.id), className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]" }, "Edit"), /* @__PURE__ */ React.createElement("a", { href: story.public_url, className: "inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]" }, "Open"), story.status === "published" ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => performAction(`unpublish-${story.id}`, replacePattern$1(endpoints.unpublishPattern, story.id)), className: "inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-amber-100 transition hover:bg-amber-400/18" }, "Unpublish") : /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => performAction(`publish-${story.id}`, replacePattern$1(endpoints.publishPattern, story.id)), className: "inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-100 transition hover:bg-emerald-400/18" }, "Publish"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => performAction(`delete-${story.id}`, replacePattern$1(endpoints.destroyPattern, story.id), "DELETE"), className: "inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/18" }, "Delete")))))))); +} +const __vite_glob_0_92 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ + __proto__: null, + default: WorldWebStoriesIndex +}, Symbol.toStringTag, { value: "Module" })); +function getCsrfToken$4() { + if (typeof document === "undefined") return ""; + return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; +} +async function requestJson$9(url, { method = "POST", body: body2 } = {}) { + const response = await fetch(url, { + method, + credentials: "same-origin", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-CSRF-TOKEN": getCsrfToken$4(), + "X-Requested-With": "XMLHttpRequest" + }, + body: body2 ? JSON.stringify(body2) : void 0 + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload?.message || payload?.errors?.story?.[0] || Object.values(payload?.errors || {})?.[0]?.[0] || "Request failed."); + } + return payload; +} +function replacePagePattern(pattern, pageId) { + return String(pattern || "").replace("__PAGE__", String(pageId)); +} +function Field({ label, children, hint }) { + return /* @__PURE__ */ React.createElement("label", { className: "block rounded-2xl border border-white/10 bg-white/[0.04] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400" }, label), /* @__PURE__ */ React.createElement("div", { className: "mt-2" }, children), hint ? /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-xs text-slate-500" }, hint) : null); +} +function StoryPageCard({ page, endpoints, onChanged }) { + const [localPage, setLocalPage] = React.useState(page); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(""); + React.useEffect(() => { + setLocalPage(page); + }, [page]); + async function save() { + setBusy(true); + setError(""); + try { + await requestJson$9(replacePagePattern(endpoints.pagesUpdatePattern, page.id), { + method: "PATCH", + body: { + ...localPage, + overlay_strength: Number(localPage.overlay_strength || 35), + active: Boolean(localPage.active) + } + }); + onChanged(); + } catch (requestError) { + setError(requestError.message || "Unable to save page."); + } finally { + setBusy(false); + } + } + async function destroy() { + setBusy(true); + setError(""); + try { + await requestJson$9(replacePagePattern(endpoints.pagesDestroyPattern, page.id), { method: "DELETE" }); + onChanged(); + } catch (requestError) { + setError(requestError.message || "Unable to delete page."); + } finally { + setBusy(false); + } + } + return /* @__PURE__ */ React.createElement("article", { className: "rounded-[24px] border border-white/10 bg-black/20 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Page ", page.position), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, page.headline || "Untitled page")), /* @__PURE__ */ React.createElement("div", { className: "flex gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: save, disabled: busy, className: "rounded-full border border-sky-300/20 bg-sky-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20 disabled:opacity-60" }, "Save"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: destroy, disabled: busy, className: "rounded-full border border-rose-300/20 bg-rose-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/20 disabled:opacity-60" }, "Delete"))), error ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100" }, error) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 xl:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field, { label: "Headline" }, /* @__PURE__ */ React.createElement("input", { value: localPage.headline || "", onChange: (event) => setLocalPage((current) => ({ ...current, headline: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Caption" }, /* @__PURE__ */ React.createElement("input", { value: localPage.caption || "", onChange: (event) => setLocalPage((current) => ({ ...current, caption: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Body", hint: "Maximum 180 characters." }, /* @__PURE__ */ React.createElement("textarea", { value: localPage.body || "", onChange: (event) => setLocalPage((current) => ({ ...current, body: event.target.value })), rows: 3, className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Alt text" }, /* @__PURE__ */ React.createElement("input", { value: localPage.alt_text || "", onChange: (event) => setLocalPage((current) => ({ ...current, alt_text: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Layout" }, /* @__PURE__ */ React.createElement("select", { value: localPage.layout, onChange: (event) => setLocalPage((current) => ({ ...current, layout: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" }, ["cover", "artwork", "creator", "mood", "collection", "cta"].map((value) => /* @__PURE__ */ React.createElement("option", { key: value, value }, value)))), /* @__PURE__ */ React.createElement(Field, { label: "Background type" }, /* @__PURE__ */ React.createElement("select", { value: localPage.background_type, onChange: (event) => setLocalPage((current) => ({ ...current, background_type: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" }, ["image", "video", "gradient"].map((value) => /* @__PURE__ */ React.createElement("option", { key: value, value }, value)))), /* @__PURE__ */ React.createElement(Field, { label: "Background path" }, /* @__PURE__ */ React.createElement("input", { value: localPage.background_path || "", onChange: (event) => setLocalPage((current) => ({ ...current, background_path: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Mobile background path" }, /* @__PURE__ */ React.createElement("input", { value: localPage.background_mobile_path || "", onChange: (event) => setLocalPage((current) => ({ ...current, background_mobile_path: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "CTA label" }, /* @__PURE__ */ React.createElement("input", { value: localPage.cta_label || "", onChange: (event) => setLocalPage((current) => ({ ...current, cta_label: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "CTA URL" }, /* @__PURE__ */ React.createElement("input", { value: localPage.cta_url || "", onChange: (event) => setLocalPage((current) => ({ ...current, cta_url: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Text position" }, /* @__PURE__ */ React.createElement("select", { value: localPage.text_position, onChange: (event) => setLocalPage((current) => ({ ...current, text_position: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" }, ["top", "center", "bottom"].map((value) => /* @__PURE__ */ React.createElement("option", { key: value, value }, value)))), /* @__PURE__ */ React.createElement(Field, { label: "Animation" }, /* @__PURE__ */ React.createElement("select", { value: localPage.animation || "", onChange: (event) => setLocalPage((current) => ({ ...current, animation: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" }, /* @__PURE__ */ React.createElement("option", { value: "" }, "None"), ["fade-in", "fly-in-bottom", "pulse", "pan-left", "pan-right"].map((value) => /* @__PURE__ */ React.createElement("option", { key: value, value }, value)))), /* @__PURE__ */ React.createElement(Field, { label: "Overlay strength" }, /* @__PURE__ */ React.createElement("input", { type: "number", min: "0", max: "100", value: localPage.overlay_strength || 35, onChange: (event) => setLocalPage((current) => ({ ...current, overlay_strength: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Artwork ID" }, /* @__PURE__ */ React.createElement("input", { type: "number", min: "1", value: localPage.artwork_id || "", onChange: (event) => setLocalPage((current) => ({ ...current, artwork_id: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Position" }, /* @__PURE__ */ React.createElement("input", { type: "number", min: "1", value: localPage.position || 1, onChange: (event) => setLocalPage((current) => ({ ...current, position: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Credit text" }, /* @__PURE__ */ React.createElement("input", { value: localPage.credit_text || "", onChange: (event) => setLocalPage((current) => ({ ...current, credit_text: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement("label", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("input", { type: "checkbox", checked: Boolean(localPage.active), onChange: (event) => setLocalPage((current) => ({ ...current, active: event.target.checked })) }), "Page active"))); +} +function WorldWebStoryEditor() { + const { props } = X$1(); + const story = props.story; + const endpoints = props.endpoints || {}; + const worldOptions = props.worldOptions || []; + const isNew = Boolean(props.isNew); + const [notice, setNotice] = React.useState(""); + const [error, setError] = React.useState(""); + const [pages2, setPages] = React.useState(story.pages || []); + const [newPage, setNewPage] = React.useState({ + layout: "cover", + background_type: "image", + headline: "", + body: "", + cta_label: "", + cta_url: "", + alt_text: "", + caption: "", + credit_text: "", + background_path: "", + background_mobile_path: "", + artwork_id: "", + text_position: "bottom", + overlay_strength: 35, + animation: "", + active: true + }); + React.useEffect(() => { + setPages(story.pages || []); + }, [story.pages]); + const form = G$1({ + world_id: story.world_id || "", + slug: story.slug || "", + title: story.title || "", + subtitle: story.subtitle || "", + excerpt: story.excerpt || "", + description: story.description || "", + seo_title: story.seo_title || "", + seo_description: story.seo_description || "", + poster_portrait_path: story.poster_portrait_path || "", + poster_square_path: story.poster_square_path || "", + publisher_logo_path: story.publisher_logo_path || "", + status: story.status || "draft", + featured: Boolean(story.featured), + active: Boolean(story.active), + noindex: Boolean(story.noindex), + published_at: story.published_at || "", + starts_at: story.starts_at || "", + ends_at: story.ends_at || "" + }); + function submit(event) { + event.preventDefault(); + setError(""); + setNotice(""); + const options = { + preserveScroll: true, + onSuccess: () => setNotice("Web story saved."), + onError: (errors) => setError(Object.values(errors)[0] || "Save failed.") + }; + if (isNew) { + form.post(endpoints.store, options); + return; + } + form.patch(endpoints.update, options); + } + async function reloadEditor() { + At.reload({ preserveScroll: true, only: ["story"] }); + } + async function createPage(event) { + event.preventDefault(); + setError(""); + setNotice(""); + try { + await requestJson$9(endpoints.pagesStore, { + body: { + ...newPage, + overlay_strength: Number(newPage.overlay_strength || 35), + artwork_id: newPage.artwork_id ? Number(newPage.artwork_id) : null, + active: Boolean(newPage.active) + } + }); + setNotice("Page created."); + setNewPage({ + layout: "cover", + background_type: "image", + headline: "", + body: "", + cta_label: "", + cta_url: "", + alt_text: "", + caption: "", + credit_text: "", + background_path: "", + background_mobile_path: "", + artwork_id: "", + text_position: "bottom", + overlay_strength: 35, + animation: "", + active: true + }); + reloadEditor(); + } catch (requestError) { + setError(requestError.message || "Unable to create page."); + } + } + async function performStoryAction(url) { + setError(""); + setNotice(""); + try { + const payload = await requestJson$9(url); + setNotice(payload.message || "Action completed."); + reloadEditor(); + } catch (requestError) { + setError(requestError.message || "Action failed."); + } + } + async function reorder(pageId, direction) { + const sorted = [...pages2].sort((left, right) => left.position - right.position); + const currentIndex = sorted.findIndex((page) => page.id === pageId); + const targetIndex = currentIndex + direction; + if (currentIndex < 0 || targetIndex < 0 || targetIndex >= sorted.length) return; + const next = [...sorted]; + [next[currentIndex], next[targetIndex]] = [next[targetIndex], next[currentIndex]]; + try { + await requestJson$9(endpoints.pagesReorder, { + body: { page_ids: next.map((page) => page.id) } + }); + reloadEditor(); + } catch (requestError) { + setError(requestError.message || "Unable to reorder pages."); + } + } + async function generateFromWorld() { + if (!form.data.world_id) return; + try { + const payload = await requestJson$9(endpoints.generateFromWorldPattern.replace("__WORLD__", String(form.data.world_id)), { + body: { force: true, pages: Math.max(5, pages2.length || 7) } + }); + setNotice(payload.message || "Draft regenerated from World."); + if (payload.story?.edit_url) { + At.visit(payload.story.edit_url); + return; + } + reloadEditor(); + } catch (requestError) { + setError(requestError.message || "Generation failed."); + } + } + return /* @__PURE__ */ React.createElement("div", { className: "w-full pb-16 pt-8" }, /* @__PURE__ */ React.createElement(Se$1, { title: isNew ? "New World Web Story" : `Edit ${story.title}` }), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80" }, "Moderation surface"), /* @__PURE__ */ React.createElement("h1", { className: "mt-2 text-3xl font-semibold tracking-[-0.04em] text-white" }, isNew ? "Create World Web Story" : story.title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-relaxed text-slate-300" }, "Build a standalone AMP story companion for a Skinbase World without changing the canonical World route.")), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement(xe, { href: endpoints.index, className: "rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]" }, "Back"), !isNew && story.public_url ? /* @__PURE__ */ React.createElement("a", { href: story.public_url, className: "rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]" }, "Open story") : null, !isNew ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => performStoryAction(endpoints.publish), className: "rounded-full border border-emerald-300/20 bg-emerald-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-100 transition hover:bg-emerald-400/18" }, "Publish") : null, !isNew ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => performStoryAction(endpoints.unpublish), className: "rounded-full border border-amber-300/20 bg-amber-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-amber-100 transition hover:bg-amber-400/18" }, "Unpublish") : null)), notice ? /* @__PURE__ */ React.createElement("div", { className: "mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50" }, notice) : null, error ? /* @__PURE__ */ React.createElement("div", { className: "mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100" }, error) : null, /* @__PURE__ */ React.createElement("form", { onSubmit: submit, className: "mt-6 rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 xl:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field, { label: "Related World" }, /* @__PURE__ */ React.createElement("select", { value: form.data.world_id, onChange: (event) => form.setData("world_id", event.target.value), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" }, /* @__PURE__ */ React.createElement("option", { value: "" }, "No related World"), worldOptions.map((world) => /* @__PURE__ */ React.createElement("option", { key: world.value, value: world.value }, world.label)))), /* @__PURE__ */ React.createElement(Field, { label: "Slug" }, /* @__PURE__ */ React.createElement("input", { value: form.data.slug, onChange: (event) => form.setData("slug", event.target.value), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Title" }, /* @__PURE__ */ React.createElement("input", { value: form.data.title, onChange: (event) => form.setData("title", event.target.value), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Subtitle" }, /* @__PURE__ */ React.createElement("input", { value: form.data.subtitle, onChange: (event) => form.setData("subtitle", event.target.value), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Excerpt" }, /* @__PURE__ */ React.createElement("textarea", { value: form.data.excerpt, onChange: (event) => form.setData("excerpt", event.target.value), rows: 3, className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Description" }, /* @__PURE__ */ React.createElement("textarea", { value: form.data.description, onChange: (event) => form.setData("description", event.target.value), rows: 3, className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "SEO title" }, /* @__PURE__ */ React.createElement("input", { value: form.data.seo_title, onChange: (event) => form.setData("seo_title", event.target.value), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "SEO description" }, /* @__PURE__ */ React.createElement("textarea", { value: form.data.seo_description, onChange: (event) => form.setData("seo_description", event.target.value), rows: 3, className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Poster portrait path" }, /* @__PURE__ */ React.createElement("input", { value: form.data.poster_portrait_path, onChange: (event) => form.setData("poster_portrait_path", event.target.value), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Poster square path" }, /* @__PURE__ */ React.createElement("input", { value: form.data.poster_square_path, onChange: (event) => form.setData("poster_square_path", event.target.value), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Publisher logo path" }, /* @__PURE__ */ React.createElement("input", { value: form.data.publisher_logo_path, onChange: (event) => form.setData("publisher_logo_path", event.target.value), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Status" }, /* @__PURE__ */ React.createElement("select", { value: form.data.status, onChange: (event) => form.setData("status", event.target.value), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" }, ["draft", "published", "archived"].map((value) => /* @__PURE__ */ React.createElement("option", { key: value, value }, value)))), /* @__PURE__ */ React.createElement(Field, { label: "Starts at" }, /* @__PURE__ */ React.createElement("input", { type: "datetime-local", value: form.data.starts_at ? form.data.starts_at.slice(0, 16) : "", onChange: (event) => form.setData("starts_at", event.target.value), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Ends at" }, /* @__PURE__ */ React.createElement("input", { type: "datetime-local", value: form.data.ends_at ? form.data.ends_at.slice(0, 16) : "", onChange: (event) => form.setData("ends_at", event.target.value), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" }))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-4" }, /* @__PURE__ */ React.createElement("label", { className: "flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("input", { type: "checkbox", checked: Boolean(form.data.featured), onChange: (event) => form.setData("featured", event.target.checked) }), " Featured"), /* @__PURE__ */ React.createElement("label", { className: "flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("input", { type: "checkbox", checked: Boolean(form.data.active), onChange: (event) => form.setData("active", event.target.checked) }), " Active"), /* @__PURE__ */ React.createElement("label", { className: "flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("input", { type: "checkbox", checked: Boolean(form.data.noindex), onChange: (event) => form.setData("noindex", event.target.checked) }), " Noindex")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: form.processing, className: "rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-2.5 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20 disabled:opacity-60" }, "Save story"), !isNew ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: generateFromWorld, className: "rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]" }, "Regenerate from World") : null)), !isNew ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold tracking-[-0.03em] text-white" }, "Validation"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-300" }, "Publish only when poster, logo, page count, alt text, and CTA rules are satisfied.")), /* @__PURE__ */ React.createElement("div", { className: `rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${story.validation?.valid ? "border-emerald-300/20 bg-emerald-400/12 text-emerald-100" : "border-amber-300/20 bg-amber-400/12 text-amber-100"}` }, story.validation?.valid ? "Ready to publish" : "Needs fixes")), (story.validation?.errors || []).length > 0 ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-4 text-sm text-amber-50" }, /* @__PURE__ */ React.createElement("ul", { className: "space-y-2" }, (story.validation.errors || []).map((item) => /* @__PURE__ */ React.createElement("li", { key: item }, item)))) : null), /* @__PURE__ */ React.createElement("section", { className: "mt-8 rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold tracking-[-0.03em] text-white" }, "Story pages"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-300" }, "Keep each page short, visual, and clearly tied back to the World narrative."))), /* @__PURE__ */ React.createElement("form", { onSubmit: createPage, className: "mt-6 grid gap-3 xl:grid-cols-2" }, /* @__PURE__ */ React.createElement(Field, { label: "New page headline" }, /* @__PURE__ */ React.createElement("input", { value: newPage.headline, onChange: (event) => setNewPage((current) => ({ ...current, headline: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "New page caption" }, /* @__PURE__ */ React.createElement("input", { value: newPage.caption, onChange: (event) => setNewPage((current) => ({ ...current, caption: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "New page body" }, /* @__PURE__ */ React.createElement("textarea", { value: newPage.body, onChange: (event) => setNewPage((current) => ({ ...current, body: event.target.value })), rows: 3, className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Alt text" }, /* @__PURE__ */ React.createElement("input", { value: newPage.alt_text, onChange: (event) => setNewPage((current) => ({ ...current, alt_text: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Layout" }, /* @__PURE__ */ React.createElement("select", { value: newPage.layout, onChange: (event) => setNewPage((current) => ({ ...current, layout: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" }, ["cover", "artwork", "creator", "mood", "collection", "cta"].map((value) => /* @__PURE__ */ React.createElement("option", { key: value, value }, value)))), /* @__PURE__ */ React.createElement(Field, { label: "Background type" }, /* @__PURE__ */ React.createElement("select", { value: newPage.background_type, onChange: (event) => setNewPage((current) => ({ ...current, background_type: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" }, ["image", "video", "gradient"].map((value) => /* @__PURE__ */ React.createElement("option", { key: value, value }, value)))), /* @__PURE__ */ React.createElement(Field, { label: "Background path" }, /* @__PURE__ */ React.createElement("input", { value: newPage.background_path, onChange: (event) => setNewPage((current) => ({ ...current, background_path: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement(Field, { label: "Mobile background path" }, /* @__PURE__ */ React.createElement("input", { value: newPage.background_mobile_path, onChange: (event) => setNewPage((current) => ({ ...current, background_mobile_path: event.target.value })), className: "w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" })), /* @__PURE__ */ React.createElement("div", { className: "xl:col-span-2 flex justify-end" }, /* @__PURE__ */ React.createElement("button", { type: "submit", className: "rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-2.5 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20" }, "Add page"))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 space-y-4" }, pages2.sort((left, right) => left.position - right.position).map((page) => /* @__PURE__ */ React.createElement("div", { key: page.id }, /* @__PURE__ */ React.createElement("div", { className: "mb-2 flex justify-end gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => reorder(page.id, -1), className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]" }, "Move up"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => reorder(page.id, 1), className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]" }, "Move down")), /* @__PURE__ */ React.createElement(StoryPageCard, { page, endpoints, onChanged: reloadEditor })))))) : null); +} +const __vite_glob_0_93 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ + __proto__: null, + default: WorldWebStoryEditor +}, Symbol.toStringTag, { value: "Module" })); const ABSOLUTE_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", @@ -88713,7 +91941,7 @@ if (typeof document !== "undefined") { clientExports.createRoot(mountEl).render(/* @__PURE__ */ React.createElement(NewsComments, { ...props })); } } -const __vite_glob_0_82 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_94 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: NewsComments }, Symbol.toStringTag, { value: "Module" })); @@ -88800,7 +92028,7 @@ if (typeof document !== "undefined") { document.addEventListener("keydown", handleKeyDown); } const NewsImagePreview = null; -const __vite_glob_0_83 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_95 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: NewsImagePreview }, Symbol.toStringTag, { value: "Module" })); @@ -89764,7 +92992,7 @@ function ProfileGallery() { } )))); } -const __vite_glob_0_84 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_96 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: ProfileGallery }, Symbol.toStringTag, { value: "Module" })); @@ -92614,7 +95842,7 @@ function ProfileShow() { } )))); } -const __vite_glob_0_85 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_97 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: ProfileShow }, Symbol.toStringTag, { value: "Module" })); @@ -94042,7 +97270,7 @@ function ProfileEdit() { ) ); } -const __vite_glob_0_86 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_98 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: ProfileEdit }, Symbol.toStringTag, { value: "Module" })); @@ -94555,7 +97783,7 @@ function StudioActivity() { /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("section", { className: "grid gap-4 md:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "New since last read"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-3xl font-semibold text-white" }, Number(summary.new_items || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Unread notifications"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-3xl font-semibold text-white" }, Number(summary.unread_notifications || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Last inbox reset"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-base font-semibold text-white" }, summary.last_read_at ? formatDate$7(summary.last_read_at) : "Not yet"))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 lg:p-6" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 md:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement("label", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Search activity"), /* @__PURE__ */ React.createElement("input", { value: filters.q || "", onChange: (event) => updateFilters({ q: event.target.value }), className: "w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white", placeholder: "Message, actor, or module" })), /* @__PURE__ */ React.createElement("div", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Type"), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.type || "all", onChange: (val) => updateFilters({ type: val }), options: typeOptions, searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Content type"), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.module || "all", onChange: (val) => updateFilters({ module: val }), options: moduleOptions, searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "flex items-end" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => updateFilters({ q: "", type: "all", module: "all" }), className: "w-full rounded-2xl border border-white/10 px-4 py-3 text-sm text-slate-200" }, "Reset")))), /* @__PURE__ */ React.createElement("section", { className: "space-y-4" }, items.length > 0 ? items.map((item) => /* @__PURE__ */ React.createElement("article", { key: item.id, className: `rounded-[28px] border p-5 ${item.is_new ? "border-sky-300/25 bg-sky-300/10" : "border-white/10 bg-white/[0.03]"}` }, /* @__PURE__ */ React.createElement("div", { className: "flex gap-4" }, item.actor?.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: item.actor.avatar_url, alt: item.actor.name || "Activity actor", className: "h-12 w-12 rounded-2xl object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-12 w-12 items-center justify-center rounded-2xl bg-black/20 text-slate-400" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-bell" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, /* @__PURE__ */ React.createElement("span", null, item.module_label), /* @__PURE__ */ React.createElement("span", null, formatDate$7(item.created_at)), item.is_new && /* @__PURE__ */ React.createElement("span", { className: "rounded-full bg-sky-300/20 px-2 py-1 text-sky-100" }, "New")), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-lg font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, item.body), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap items-center gap-3 text-sm text-slate-400" }, item.actor?.name && /* @__PURE__ */ React.createElement("span", null, item.actor.name), /* @__PURE__ */ React.createElement("a", { href: item.url, className: "inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1.5 text-slate-200" }, "Open")))))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-dashed border-white/15 px-6 py-16 text-center text-slate-400" }, "No activity matches this filter.")), /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("button", { type: "button", disabled: (meta.current_page || 1) <= 1, onClick: () => updateFilters({ page: Math.max(1, (meta.current_page || 1) - 1) }), className: "rounded-full border border-white/10 px-4 py-2 disabled:opacity-40" }, "Previous"), /* @__PURE__ */ React.createElement("span", { className: "text-xs uppercase tracking-[0.18em] text-slate-500" }, "Page ", meta.current_page || 1, " of ", meta.last_page || 1), /* @__PURE__ */ React.createElement("button", { type: "button", disabled: (meta.current_page || 1) >= (meta.last_page || 1), onClick: () => updateFilters({ page: (meta.current_page || 1) + 1 }), className: "rounded-full border border-white/10 px-4 py-2 disabled:opacity-40" }, "Next"))) ); } -const __vite_glob_0_87 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_99 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioActivity }, Symbol.toStringTag, { value: "Module" })); @@ -94646,7 +97874,7 @@ function StudioAnalytics() { return /* @__PURE__ */ React.createElement("div", { key: item.key, className: "rounded-[22px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 text-slate-200" }, /* @__PURE__ */ React.createElement("i", { className: item.icon }), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "font-semibold text-white" }, item.label), /* @__PURE__ */ React.createElement("div", { className: "text-xs text-slate-400" }, Number(item.published_count || 0).toLocaleString(), " published"))), /* @__PURE__ */ React.createElement("a", { href: moduleBreakdown?.find((entry) => entry.key === item.key)?.index_url, className: "text-xs font-semibold uppercase tracking-[0.18em] text-sky-100" }, "Open")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between text-xs text-slate-400" }, /* @__PURE__ */ React.createElement("span", null, "Views"), /* @__PURE__ */ React.createElement("span", null, Number(item.views || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "mt-2 h-2 overflow-hidden rounded-full bg-white/5" }, /* @__PURE__ */ React.createElement("div", { className: "h-full rounded-full bg-emerald-400/60", style: { width: `${Math.max(4, Math.round(Number(item.views || 0) / viewMax * 100))}%` } }))), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between text-xs text-slate-400" }, /* @__PURE__ */ React.createElement("span", null, "Engagement"), /* @__PURE__ */ React.createElement("span", null, Number(item.engagement || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "mt-2 h-2 overflow-hidden rounded-full bg-white/5" }, /* @__PURE__ */ React.createElement("div", { className: "h-full rounded-full bg-pink-400/60", style: { width: `${Math.max(4, Math.round(Number(item.engagement || 0) / engagementMax * 100))}%` } }))))); }))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Readable insights"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3 text-sm text-slate-400" }, (insightBlocks || []).map((item) => /* @__PURE__ */ React.createElement("a", { key: item.key, href: item.href, className: "block rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex h-10 w-10 items-center justify-center rounded-2xl bg-white/[0.04] text-sky-100" }, /* @__PURE__ */ React.createElement("i", { className: item.icon })), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h3", { className: "text-sm font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 leading-6 text-slate-400" }, item.body), /* @__PURE__ */ React.createElement("span", { className: "mt-3 inline-flex items-center gap-2 text-sm font-medium text-sky-100" }, item.cta, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-right" }))))))))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Top content"), /* @__PURE__ */ React.createElement("div", { className: "mt-5 overflow-x-auto" }, /* @__PURE__ */ React.createElement("table", { className: "w-full text-sm" }, /* @__PURE__ */ React.createElement("thead", null, /* @__PURE__ */ React.createElement("tr", { className: "border-b border-white/5 text-left text-[11px] uppercase tracking-[0.18em] text-slate-500" }, /* @__PURE__ */ React.createElement("th", { className: "pb-3 pr-4" }, "Module"), /* @__PURE__ */ React.createElement("th", { className: "pb-3 pr-4" }, "Title"), /* @__PURE__ */ React.createElement("th", { className: "pb-3 pr-4 text-right" }, "Views"), /* @__PURE__ */ React.createElement("th", { className: "pb-3 pr-4 text-right" }, "Reactions"), /* @__PURE__ */ React.createElement("th", { className: "pb-3 pr-4 text-right" }, "Comments"), /* @__PURE__ */ React.createElement("th", { className: "pb-3 text-right" }, "Open"))), /* @__PURE__ */ React.createElement("tbody", { className: "divide-y divide-white/5" }, (topContent || []).map((item) => /* @__PURE__ */ React.createElement("tr", { key: item.id }, /* @__PURE__ */ React.createElement("td", { className: "py-3 pr-4 text-slate-300" }, item.module_label), /* @__PURE__ */ React.createElement("td", { className: "py-3 pr-4 text-white" }, item.title), /* @__PURE__ */ React.createElement("td", { className: "py-3 pr-4 text-right text-slate-300" }, Number(item.metrics?.views || 0).toLocaleString()), /* @__PURE__ */ React.createElement("td", { className: "py-3 pr-4 text-right text-slate-300" }, Number(item.metrics?.appreciation || 0).toLocaleString()), /* @__PURE__ */ React.createElement("td", { className: "py-3 pr-4 text-right text-slate-300" }, Number(item.metrics?.comments || 0).toLocaleString()), /* @__PURE__ */ React.createElement("td", { className: "py-3 text-right" }, /* @__PURE__ */ React.createElement("a", { href: item.analytics_url || item.view_url, className: "text-sky-100" }, "Open")))))))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Recent comments"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, (recentComments || []).map((comment) => /* @__PURE__ */ React.createElement("article", { key: comment.id, className: "rounded-[22px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70" }, comment.module_label), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-white" }, comment.author_name, " on ", comment.item_title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, comment.body))))))); } -const __vite_glob_0_88 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_100 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioAnalytics }, Symbol.toStringTag, { value: "Module" })); @@ -94729,7 +97957,7 @@ function itemReadiness(item) { if (item?.status === "published") return null; return item?.workflow?.readiness ?? null; } -function buildPaginationPages(current, last) { +function buildPaginationPages$1(current, last) { if (last <= 1) return [1]; if (last <= 7) { return Array.from({ length: last }, (_2, index2) => index2 + 1); @@ -94912,7 +98140,7 @@ function StudioContentBrowser({ const perPage = Math.max(1, Number(meta.per_page || visibleItems.length || 24)); const rangeStart = visibleTotal === 0 ? 0 : (currentPage - 1) * perPage + 1; const rangeEnd = visibleTotal === 0 ? 0 : Math.min(visibleTotal, rangeStart + Math.max(visibleItems.length, 1) - 1); - const paginationPages = buildPaginationPages(currentPage, lastPage); + const paginationPages = buildPaginationPages$1(currentPage, lastPage); const filterControlCount = 1 + (hideModuleFilter ? 0 : 1) + (hideBucketFilter ? 0 : 1) + 1 + advancedFilters.length + 1; const filterGridClass = filterControlCount <= 4 ? "xl:grid-cols-4" : filterControlCount === 5 ? "xl:grid-cols-5" : filterControlCount === 6 ? "xl:grid-cols-6" : "xl:grid-cols-6 2xl:grid-cols-7"; reactExports.useEffect(() => { @@ -95462,7 +98690,7 @@ function StudioArchived() { } )); } -const __vite_glob_0_89 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_101 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioArchived }, Symbol.toStringTag, { value: "Module" })); @@ -95498,7 +98726,7 @@ function StudioArtworkAnalytics() { } ), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-bold text-white" }, artwork?.title), /* @__PURE__ */ React.createElement("p", { className: "text-xs text-slate-500 mt-1" }, "/", artwork?.slug))), /* @__PURE__ */ React.createElement("div", { className: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8" }, kpiItems$1.map((item) => /* @__PURE__ */ React.createElement("div", { key: item.key, className: "bg-nova-900/60 border border-white/10 rounded-2xl p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-2 mb-2" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${item.icon} ${item.color}` }), /* @__PURE__ */ React.createElement("span", { className: "text-xs font-medium text-slate-400 uppercase tracking-wider" }, item.label)), /* @__PURE__ */ React.createElement("p", { className: "text-2xl font-bold text-white tabular-nums" }, (analytics?.[item.key] ?? 0).toLocaleString())))), /* @__PURE__ */ React.createElement("h3", { className: "text-base font-bold text-white mb-4" }, "Performance Metrics"), /* @__PURE__ */ React.createElement("div", { className: "grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8" }, metricCards.map((item) => /* @__PURE__ */ React.createElement("div", { key: item.key, className: "bg-nova-900/60 border border-white/10 rounded-2xl p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-2 mb-3" }, /* @__PURE__ */ React.createElement("div", { className: `w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center ${item.color}` }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${item.icon} text-lg` })), /* @__PURE__ */ React.createElement("span", { className: "text-sm font-medium text-slate-300" }, item.label)), /* @__PURE__ */ React.createElement("p", { className: "text-3xl font-bold text-white tabular-nums" }, (analytics?.[item.key] ?? 0).toFixed(1))))), /* @__PURE__ */ React.createElement("div", { className: "grid grid-cols-1 lg:grid-cols-2 gap-4" }, /* @__PURE__ */ React.createElement("div", { className: "bg-nova-900/40 border border-white/10 rounded-2xl p-6" }, /* @__PURE__ */ React.createElement("h4", { className: "text-sm font-semibold text-white mb-3" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-chart-line mr-2 text-slate-500" }), "Traffic Sources"), /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-center py-8" }, /* @__PURE__ */ React.createElement("div", { className: "text-center" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-chart-pie text-3xl text-slate-700 mb-3" }), /* @__PURE__ */ React.createElement("p", { className: "text-xs text-slate-500" }, "Coming soon"), /* @__PURE__ */ React.createElement("p", { className: "text-[10px] text-slate-600 mt-1" }, "Traffic source tracking is on the roadmap")))), /* @__PURE__ */ React.createElement("div", { className: "bg-nova-900/40 border border-white/10 rounded-2xl p-6" }, /* @__PURE__ */ React.createElement("h4", { className: "text-sm font-semibold text-white mb-3" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-share-from-square mr-2 text-slate-500" }), "Shares by Platform"), /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-center py-8" }, /* @__PURE__ */ React.createElement("div", { className: "text-center" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-share-nodes text-3xl text-slate-700 mb-3" }), /* @__PURE__ */ React.createElement("p", { className: "text-xs text-slate-500" }, "Coming soon"), /* @__PURE__ */ React.createElement("p", { className: "text-[10px] text-slate-600 mt-1" }, "Per-platform breakdown coming in a future update")))), /* @__PURE__ */ React.createElement("div", { className: "bg-nova-900/40 border border-white/10 rounded-2xl p-6 lg:col-span-2" }, /* @__PURE__ */ React.createElement("h4", { className: "text-sm font-semibold text-white mb-3" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-trophy mr-2 text-slate-500" }), "Ranking History"), /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-center py-8" }, /* @__PURE__ */ React.createElement("div", { className: "text-center" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-chart-area text-3xl text-slate-700 mb-3" }), /* @__PURE__ */ React.createElement("p", { className: "text-xs text-slate-500" }, "Coming soon"), /* @__PURE__ */ React.createElement("p", { className: "text-[10px] text-slate-600 mt-1" }, "Historical ranking data will be tracked in a future update")))))); } -const __vite_glob_0_90 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_102 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioArtworkAnalytics }, Symbol.toStringTag, { value: "Module" })); @@ -98055,7 +101283,7 @@ function StudioArtworkEdit() { )), historyData.versions.length === 0 && /* @__PURE__ */ React.createElement("p", { className: "text-sm text-slate-500 text-center py-8" }, "No version history yet.")) )); } -const __vite_glob_0_91 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_103 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioArtworkEdit }, Symbol.toStringTag, { value: "Module" })); @@ -98067,7 +101295,7 @@ function StudioArtworks() { const summary = props.summary || {}; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("div", { className: "mb-6 grid gap-4 md:grid-cols-4" }, /* @__PURE__ */ React.createElement(SummaryCard$3, { label: "Artworks", value: summary.count, icon: "fa-solid fa-images" }), /* @__PURE__ */ React.createElement(SummaryCard$3, { label: "Drafts", value: summary.draft_count, icon: "fa-solid fa-file-pen" }), /* @__PURE__ */ React.createElement(SummaryCard$3, { label: "Published", value: summary.published_count, icon: "fa-solid fa-rocket" }), /* @__PURE__ */ React.createElement("a", { href: "/upload", className: "rounded-[24px] border border-sky-300/20 bg-sky-300/10 p-5 text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em]" }, "Upload artwork"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6" }, "Start a new visual upload flow without leaving Creator Studio."))), /* @__PURE__ */ React.createElement(StudioContentBrowser, { listing: props.listing, quickCreate: props.quickCreate, hideModuleFilter: true })); } -const __vite_glob_0_92 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_104 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioArtworks }, Symbol.toStringTag, { value: "Module" })); @@ -98193,7 +101421,7 @@ function StudioAssets() { /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-right" }) )))); } -const __vite_glob_0_93 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_105 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioAssets }, Symbol.toStringTag, { value: "Module" })); @@ -98365,7 +101593,7 @@ function StudioCalendar() { }, [selectedDay]); return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("section", { className: "grid gap-4 md:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Scheduled"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-3xl font-semibold text-white" }, Number(summary.scheduled_total || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Unscheduled"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-3xl font-semibold text-white" }, Number(summary.unscheduled_total || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Overloaded days"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-3xl font-semibold text-white" }, Number(summary.overloaded_days || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Next publish"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-base font-semibold text-white" }, formatReleaseCountdown(summary.next_publish_at, nowMs)), summary.next_publish_at && /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-sm text-slate-400" }, formatScheduledDate(summary.next_publish_at)))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 lg:p-6" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 md:grid-cols-2 xl:grid-cols-5" }, /* @__PURE__ */ React.createElement("label", { className: "space-y-2 text-sm text-slate-300 xl:col-span-2" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Search planning queue"), /* @__PURE__ */ React.createElement("input", { value: filters.q || "", onChange: (event) => updateFilters({ q: event.target.value }), className: "w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white", placeholder: "Title or module" })), /* @__PURE__ */ React.createElement("div", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "View"), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.view || "month", onChange: (val) => updateFilters({ view: val }), options: calendar.view_options || [], searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Module"), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.module || "all", onChange: (val) => updateFilters({ module: val }), options: calendar.module_options || [], searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Queue"), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.status || "scheduled", onChange: (val) => updateFilters({ status: val }), options: calendar.status_options || [], searchable: false })))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 xl:grid-cols-[minmax(0,1fr)_340px]" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-5" }, filters.view === "week" ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, calendar.week?.label), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs uppercase tracking-[0.18em] text-slate-500" }, "Week planning")), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => shiftCalendar(-1), className: "rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100" }, "Prev week"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: resetCalendarFocus, className: "rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100" }, "Today"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => shiftCalendar(1), className: "rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100" }, "Next week"))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-7" }, (calendar.week?.days || []).map((day) => /* @__PURE__ */ React.createElement("div", { key: day.date, className: "rounded-[22px] border border-white/10 bg-black/20 p-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold text-white" }, day.label), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2" }, day.items.length > 0 ? day.items.map((item) => /* @__PURE__ */ React.createElement(CalendarInlineItem, { key: item.id, item })) : /* @__PURE__ */ React.createElement("div", { className: "text-xs text-slate-500" }, "No scheduled items")))))) : filters.view === "agenda" ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Agenda"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-4" }, (calendar.agenda || []).map((group) => /* @__PURE__ */ React.createElement("div", { key: group.date, className: "rounded-[22px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-base font-semibold text-white" }, group.label), /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.18em] text-slate-500" }, group.count, " items")), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2" }, group.items.map((item) => /* @__PURE__ */ React.createElement(CalendarInlineItem, { key: item.id, item }))))))) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, calendar.month?.label), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs uppercase tracking-[0.18em] text-slate-500" }, "Month planning")), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => shiftCalendar(-1), className: "rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100" }, "Prev month"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: resetCalendarFocus, className: "rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100" }, "Today"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => shiftCalendar(1), className: "rounded-full border border-white/10 px-3 py-1.5 text-sm text-slate-200 transition hover:border-sky-300/20 hover:bg-sky-300/10 hover:text-sky-100" }, "Next month"))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid grid-cols-7 gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((label) => /* @__PURE__ */ React.createElement("div", { key: label, className: "px-2 py-1" }, label))), /* @__PURE__ */ React.createElement("div", { className: "mt-2 grid grid-cols-7 gap-2" }, (calendar.month?.days || []).map((day) => /* @__PURE__ */ React.createElement(CalendarMonthDay, { key: day.date, day, onOpenDetail: setSelectedDay }))))), /* @__PURE__ */ React.createElement("aside", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Coverage gaps"), /* @__PURE__ */ React.createElement("a", { href: "/studio/drafts", className: "text-sm font-medium text-sky-100" }, "Open drafts")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, (calendar.gaps || []).length > 0 ? (calendar.gaps || []).map((gap) => /* @__PURE__ */ React.createElement("div", { key: gap.date, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200" }, gap.label)) : /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-dashed border-white/15 px-4 py-8 text-sm text-slate-500" }, "No empty days in the next two weeks."))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Unscheduled queue"), /* @__PURE__ */ React.createElement("span", { className: "text-xs uppercase tracking-[0.18em] text-slate-500" }, (calendar.unscheduled_items || []).length)), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, (calendar.unscheduled_items || []).map((item) => /* @__PURE__ */ React.createElement("a", { key: item.id, href: item.edit_url || item.manage_url, className: "block rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs text-slate-500" }, item.module_label, " · ", item.workflow?.readiness?.label || "Needs review"))))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Upcoming actions"), /* @__PURE__ */ React.createElement("a", { href: "/studio/scheduled", className: "text-sm font-medium text-sky-100" }, "Open list")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, (calendar.scheduled_items || []).slice(0, 5).map((item) => /* @__PURE__ */ React.createElement("div", { key: item.id, className: "rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs font-medium text-sky-200" }, formatReleaseCountdown(item.scheduled_at, nowMs)), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs text-slate-500" }, formatScheduledDate(item.scheduled_at)), /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", disabled: busyKey === `publish:${item.id}`, onClick: () => runAction(props.endpoints.publishNowPattern, item, "publish"), className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs text-sky-100 disabled:opacity-50" }, "Publish now"), /* @__PURE__ */ React.createElement("button", { type: "button", disabled: busyKey === `unschedule:${item.id}`, onClick: () => runAction(props.endpoints.unschedulePattern, item, "unschedule"), className: "rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-200 disabled:opacity-50" }, "Unschedule")))))))), /* @__PURE__ */ React.createElement(CalendarDayModal, { day: selectedDay, busyKey, endpoints: props.endpoints, onAction: runAction, onClose: () => setSelectedDay(null), nowMs }))); } -const __vite_glob_0_94 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_106 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioCalendar }, Symbol.toStringTag, { value: "Module" })); @@ -98387,7 +101615,7 @@ function StudioCardAnalytics() { const { card, analytics } = props; return /* @__PURE__ */ React.createElement(StudioLayout, { title: `Analytics: ${card?.title || "Nova Card"}` }, /* @__PURE__ */ React.createElement(xe, { href: "/studio/cards", className: "mb-6 inline-flex items-center gap-2 text-sm text-slate-400 transition-colors hover:text-white" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-left" }), "Back to Cards"), /* @__PURE__ */ React.createElement("div", { className: "mb-8 flex items-center gap-4 rounded-2xl border border-white/10 bg-nova-900/60 p-4" }, card?.preview_url ? /* @__PURE__ */ React.createElement("img", { src: card.preview_url, alt: card.title, className: "h-20 w-20 rounded-xl object-cover bg-nova-800" }) : null, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-bold text-white" }, card?.title), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-xs text-slate-500" }, "/", card?.slug), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-xs uppercase tracking-[0.18em] text-slate-400" }, card?.status, " • ", card?.visibility))), /* @__PURE__ */ React.createElement("div", { className: "mb-8 grid grid-cols-2 gap-4 sm:grid-cols-3 xl:grid-cols-6" }, kpiItems.map((item) => /* @__PURE__ */ React.createElement("div", { key: item.key, className: "rounded-2xl border border-white/10 bg-nova-900/60 p-5" }, /* @__PURE__ */ React.createElement("div", { className: "mb-2 flex items-center gap-2" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${item.icon} ${item.color}` }), /* @__PURE__ */ React.createElement("span", { className: "text-xs font-medium uppercase tracking-wider text-slate-400" }, item.label)), /* @__PURE__ */ React.createElement("p", { className: "text-2xl font-bold tabular-nums text-white" }, (analytics?.[item.key] ?? 0).toLocaleString())))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-nova-900/40 p-6" }, /* @__PURE__ */ React.createElement("h3", { className: "mb-4 text-sm font-semibold uppercase tracking-[0.18em] text-slate-300" }, "Ranking signals"), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 sm:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.18em] text-slate-400" }, "Trending score"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-3xl font-bold tabular-nums text-white" }, Number(analytics?.trending_score ?? 0).toFixed(2))), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/[0.03] p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.18em] text-slate-400" }, "Last engaged"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-sm text-white" }, analytics?.last_engaged_at || "No activity yet")))), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-nova-900/40 p-6" }, /* @__PURE__ */ React.createElement("h3", { className: "mb-4 text-sm font-semibold uppercase tracking-[0.18em] text-slate-300" }, "Secondary metrics"), /* @__PURE__ */ React.createElement("div", { className: "space-y-3" }, secondaryItems.map((item) => /* @__PURE__ */ React.createElement("div", { key: item.key, className: "flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${item.icon} text-slate-500` }), item.label), /* @__PURE__ */ React.createElement("div", { className: "text-base font-semibold tabular-nums text-white" }, (analytics?.[item.key] ?? 0).toLocaleString()))))))); } -const __vite_glob_0_95 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_107 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioCardAnalytics }, Symbol.toStringTag, { value: "Module" })); @@ -99531,7 +102759,7 @@ function StudioCardEditor() { fmt2.label ))), exportStatus && /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex items-center gap-3 rounded-[18px] border border-white/10 bg-white/[0.03] p-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-xs font-semibold uppercase tracking-[0.18em] text-slate-400" }, "Export status"), /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold capitalize text-white" }, exportStatus.status)), exportStatus.status === "ready" && exportStatus.output_url && /* @__PURE__ */ React.createElement("a", { href: exportStatus.output_url, download: true, className: "ml-auto rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-1.5 text-xs font-semibold text-emerald-200 transition hover:bg-emerald-400/15" }, "Download"))))), /* @__PURE__ */ React.createElement("nav", { className: "sticky bottom-0 z-20 mt-6 border-t border-white/10 bg-[rgba(2,6,23,0.92)] px-4 py-3 backdrop-blur xl:hidden" }, /* @__PURE__ */ React.createElement("div", { className: "mx-auto flex max-w-7xl items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => goToNextTab(-1), disabled: tabIndex === 0, className: "rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08] disabled:opacity-50" }, "Back"), /* @__PURE__ */ React.createElement("div", { className: "text-center" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Step ", tabIndex + 1, " / ", editorTabs.length), /* @__PURE__ */ React.createElement("div", { className: "mt-0.5 text-sm font-semibold text-white" }, editorTabs[tabIndex]?.label)), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => goToNextTab(1), disabled: tabIndex >= editorTabs.length - 1, className: "rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2.5 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-50" }, "Next")))); } -const __vite_glob_0_96 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_108 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioCardEditor }, Symbol.toStringTag, { value: "Module" })); @@ -99543,7 +102771,7 @@ function StudioCardsIndex() { const summary = props.summary || {}; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.15),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between" }, /* @__PURE__ */ React.createElement("div", { className: "max-w-3xl" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/75" }, "Creation surface"), /* @__PURE__ */ React.createElement("h2", { className: "mt-3 text-3xl font-semibold tracking-[-0.04em] text-white" }, "Build quote cards, mood cards, and visual text art."), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-7 text-slate-300" }, "Cards now live inside the same shared Creator Studio queue as artworks, collections, and stories, while keeping the dedicated editor and analytics flow.")), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-3" }, /* @__PURE__ */ React.createElement("a", { href: "/studio/cards/create", className: "inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-plus" }), "New card"), /* @__PURE__ */ React.createElement("a", { href: props.publicBrowseUrl, className: "inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-compass" }), "Browse public cards")))), /* @__PURE__ */ React.createElement("section", { className: "mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement(StatCard$1, { label: "All cards", value: summary.count || 0, icon: "fa-layer-group" }), /* @__PURE__ */ React.createElement(StatCard$1, { label: "Drafts", value: summary.draft_count || 0, icon: "fa-file-lines" }), /* @__PURE__ */ React.createElement(StatCard$1, { label: "Archived", value: summary.archived_count || 0, icon: "fa-box-archive" }), /* @__PURE__ */ React.createElement(StatCard$1, { label: "Published", value: summary.published_count || 0, icon: "fa-earth-americas" })), /* @__PURE__ */ React.createElement("section", { className: "mt-8" }, /* @__PURE__ */ React.createElement(StudioContentBrowser, { listing: props.listing, quickCreate: props.quickCreate, hideModuleFilter: true, emptyTitle: "No cards yet", emptyBody: "Create your first Nova card and it will appear here alongside your other Creator Studio content." }))); } -const __vite_glob_0_97 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_109 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioCardsIndex }, Symbol.toStringTag, { value: "Module" })); @@ -99616,7 +102844,7 @@ function StudioChallenges() { "Challenge" ), /* @__PURE__ */ React.createElement("a", { href: entry.card.edit_url, className: "text-slate-300" }, "Edit card"), /* @__PURE__ */ React.createElement("a", { href: entry.card.analytics_url, className: "text-slate-300" }, "Analytics")))))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Cards with challenge traction"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, (cardLeaders || []).map((card) => /* @__PURE__ */ React.createElement("div", { key: card.id, className: "rounded-[22px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold text-white" }, card.title), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs uppercase tracking-[0.16em] text-slate-500" }, card.status, " • ", card.challenge_entries_count, " challenge entries")), /* @__PURE__ */ React.createElement("a", { href: card.edit_url, className: "text-xs font-semibold uppercase tracking-[0.16em] text-sky-100" }, "Open")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid grid-cols-2 gap-3 text-sm text-slate-400" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", null, "Views"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, Number(card.views_count || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", null, "Comments"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, Number(card.comments_count || 0).toLocaleString()))))))))); } -const __vite_glob_0_98 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_110 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioChallenges }, Symbol.toStringTag, { value: "Module" })); @@ -99628,7 +102856,7 @@ function StudioCollections() { const summary = props.summary || {}; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("div", { className: "mb-6 grid gap-4 md:grid-cols-4" }, /* @__PURE__ */ React.createElement(SummaryCard$2, { label: "Collections", value: summary.count, icon: "fa-solid fa-layer-group" }), /* @__PURE__ */ React.createElement(SummaryCard$2, { label: "Drafts", value: summary.draft_count, icon: "fa-solid fa-file-pen" }), /* @__PURE__ */ React.createElement(SummaryCard$2, { label: "Published", value: summary.published_count, icon: "fa-solid fa-rocket" }), /* @__PURE__ */ React.createElement("a", { href: props.dashboardUrl, className: "rounded-[24px] border border-sky-300/20 bg-sky-300/10 p-5 text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em]" }, "Collection dashboard"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6" }, "Open the full collection workflow surface for rules, history, and collaboration."))), /* @__PURE__ */ React.createElement(StudioContentBrowser, { listing: props.listing, quickCreate: props.quickCreate, hideModuleFilter: true })); } -const __vite_glob_0_99 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_111 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioCollections }, Symbol.toStringTag, { value: "Module" })); @@ -99915,7 +103143,7 @@ function StudioComments() { /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-right" }) ))))); } -const __vite_glob_0_100 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_112 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioComments }, Symbol.toStringTag, { value: "Module" })); @@ -99931,7 +103159,7 @@ function StudioContentIndex() { } )); } -const __vite_glob_0_101 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_113 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioContentIndex }, Symbol.toStringTag, { value: "Module" })); @@ -100057,7 +103285,7 @@ function StudioDashboard() { ["Comments", analytics.totals?.comments] ].map(([label, value]) => /* @__PURE__ */ React.createElement("div", { key: label, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", null, label), /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, Number(value || 0).toLocaleString()))))))), showWidget("stale_drafts") && /* @__PURE__ */ React.createElement("div", { className: "mt-6 rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Stale drafts"), /* @__PURE__ */ React.createElement("a", { href: "/studio/content?bucket=drafts&stale=only&module=stories", className: "text-sm font-medium text-sky-100" }, "Filter stale work")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-4" }, (overview.stale_drafts || []).map((item) => /* @__PURE__ */ React.createElement(ContinueWorkingCard, { key: item.id, item }))))); } -const __vite_glob_0_102 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_114 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioDashboard }, Symbol.toStringTag, { value: "Module" })); @@ -100074,7 +103302,7 @@ function StudioDrafts() { } )); } -const __vite_glob_0_103 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_115 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioDrafts }, Symbol.toStringTag, { value: "Module" })); @@ -100157,7 +103385,7 @@ function StudioFeatured() { })) : /* @__PURE__ */ React.createElement("div", { className: "mt-5 rounded-[24px] border border-dashed border-white/15 px-6 py-10 text-center text-sm text-slate-400" }, "No published ", module.label.toLowerCase(), " candidates yet.")); }))); } -const __vite_glob_0_104 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_116 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioFeatured }, Symbol.toStringTag, { value: "Module" })); @@ -100183,7 +103411,7 @@ function StudioFollowers() { }; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("div", { className: "mb-6 grid gap-4 md:grid-cols-3" }, /* @__PURE__ */ React.createElement(SummaryCard$1, { label: "Total followers", value: summary.total_followers, icon: "fa-solid fa-user-group" }), /* @__PURE__ */ React.createElement(SummaryCard$1, { label: "Following back", value: summary.following_back, icon: "fa-solid fa-arrows-rotate" }), /* @__PURE__ */ React.createElement(SummaryCard$1, { label: "Not followed yet", value: summary.not_followed, icon: "fa-solid fa-user-plus" })), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 lg:grid-cols-[minmax(0,1fr)_220px_220px]" }, /* @__PURE__ */ React.createElement("label", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Search"), /* @__PURE__ */ React.createElement("input", { value: filters.q || "", onChange: (event) => updateQuery({ q: event.target.value, page: 1 }), placeholder: "Search followers", className: "w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white" })), /* @__PURE__ */ React.createElement("div", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Sort"), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.sort || "recent", onChange: (val) => updateQuery({ sort: val, page: 1 }), options: listing.sort_options || [], searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500" }, "Relationship"), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.relationship || "all", onChange: (val) => updateQuery({ relationship: val, page: 1 }), options: listing.relationship_options || [], searchable: false }))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 space-y-3" }, items.map((item) => /* @__PURE__ */ React.createElement("article", { key: item.id, className: "flex flex-col gap-4 rounded-[24px] border border-white/10 bg-black/20 p-4 md:flex-row md:items-center md:justify-between" }, /* @__PURE__ */ React.createElement("a", { href: item.profile_url, className: "flex min-w-0 items-center gap-4" }, item.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: item.avatar_url, alt: item.username, className: "h-14 w-14 rounded-[18px] object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-14 w-14 items-center justify-center rounded-[18px] bg-white/5 text-slate-400" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-user" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0" }, /* @__PURE__ */ React.createElement("div", { className: "truncate text-base font-semibold text-white" }, item.name), /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-400" }, "@", item.username))), /* @__PURE__ */ React.createElement("div", { className: "grid grid-cols-2 gap-4 text-sm text-slate-400 md:grid-cols-4 md:text-right" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", null, "Uploads"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, Number(item.uploads_count || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", null, "Followers"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, Number(item.followers_count || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", null, "Followed"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, item.followed_at ? new Date(item.followed_at).toLocaleDateString() : "—")), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", null, "Status"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, item.is_following_back ? "Following back" : "Not followed")))))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("button", { type: "button", disabled: (meta.current_page || 1) <= 1, onClick: () => updateQuery({ page: Math.max(1, (meta.current_page || 1) - 1) }), className: "inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 disabled:opacity-40" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-left" }), "Previous"), /* @__PURE__ */ React.createElement("span", null, "Page ", meta.current_page || 1, " of ", meta.last_page || 1), /* @__PURE__ */ React.createElement("button", { type: "button", disabled: (meta.current_page || 1) >= (meta.last_page || 1), onClick: () => updateQuery({ page: (meta.current_page || 1) + 1 }), className: "inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 disabled:opacity-40" }, "Next", /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-right" }))))); } -const __vite_glob_0_105 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_117 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioFollowers }, Symbol.toStringTag, { value: "Module" })); @@ -100192,7 +103420,7 @@ function StudioGroupActivity() { const items = Array.isArray(props.activity) ? props.activity : []; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("div", { className: "space-y-4" }, items.length > 0 ? items.map((item) => /* @__PURE__ */ React.createElement("div", { key: item.id, className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("h2", { className: "text-base font-semibold text-white" }, item.headline), item.is_pinned ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/20 bg-amber-300/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100" }, "Pinned") : null, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, item.visibility)), item.summary ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, item.summary) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-3 text-xs text-slate-500" }, item.actor?.name || item.actor?.username || "System", " • ", item.occurred_at ? new Date(item.occurred_at).toLocaleString() : "Recently"), item.subject?.url ? /* @__PURE__ */ React.createElement("a", { href: item.subject.url, className: "mt-3 inline-flex text-sm font-semibold text-sky-200" }, "Open subject") : null), props.pinPattern ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => At.post(props.pinPattern.replace("__ITEM__", String(item.id)), { is_pinned: !item.is_pinned }), className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-sm font-semibold text-white" }, item.is_pinned ? "Unpin" : "Pin") : null))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400" }, "No activity yet."))); } -const __vite_glob_0_106 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_118 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupActivity }, Symbol.toStringTag, { value: "Module" })); @@ -100200,7 +103428,7 @@ function StudioGroupArtworks() { const { props } = X$1(); return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("div", { className: "mb-6 rounded-[28px] border border-sky-300/20 bg-sky-300/10 p-5 text-sky-100" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em]" }, "Group publish flow"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-xl font-semibold" }, "Upload into ", props.studioGroup?.name), /* @__PURE__ */ React.createElement("a", { href: props.uploadUrl, className: "mt-4 inline-flex rounded-full border border-sky-200/20 bg-sky-200/10 px-4 py-2 text-sm font-semibold text-sky-50" }, "New group artwork")), /* @__PURE__ */ React.createElement(StudioContentBrowser, { listing: props.listing, quickCreate: [{ key: "artworks", label: "Artwork", icon: "fa-solid fa-cloud-arrow-up", url: props.uploadUrl }], hideModuleFilter: true })); } -const __vite_glob_0_107 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_119 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupArtworks }, Symbol.toStringTag, { value: "Module" })); @@ -100241,7 +103469,7 @@ function StudioGroupAssets() { }; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, props.storeUrl ? /* @__PURE__ */ React.createElement("form", { onSubmit: submit, className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 lg:grid-cols-6" }, /* @__PURE__ */ React.createElement("input", { value: form.data.title, onChange: (event) => form.setData("title", event.target.value), placeholder: "Asset title", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none lg:col-span-2" }), /* @__PURE__ */ React.createElement(NovaSelect, { value: form.data.category, onChange: (val) => form.setData("category", val), options: props.categoryOptions || [], searchable: false }), /* @__PURE__ */ React.createElement(NovaSelect, { value: form.data.visibility, onChange: (val) => form.setData("visibility", val), options: props.visibilityOptions || [], searchable: false }), /* @__PURE__ */ React.createElement(NovaSelect, { value: form.data.status, onChange: (val) => form.setData("status", val), options: props.statusOptions || [], searchable: false }), /* @__PURE__ */ React.createElement("input", { type: "file", onChange: (event) => form.setData("file", event.target.files?.[0] || null), className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("textarea", { value: form.data.description, onChange: (event) => form.setData("description", event.target.value), placeholder: "What is this asset for?", rows: 3, className: "mt-4 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: String(form.data.linked_project_id || ""), onChange: (val) => form.setData("linked_project_id", val), placeholder: "No linked project", options: (props.projectOptions || []).map((o) => ({ value: String(o.id), label: o.title })) }), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white" }, /* @__PURE__ */ React.createElement(Checkbox, { checked: form.data.is_featured, onChange: (event) => form.setData("is_featured", event.target.checked), label: "Featured asset" }))), /* @__PURE__ */ React.createElement("button", { type: "submit", className: "mt-4 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white" }, "Upload asset")) : null, /* @__PURE__ */ React.createElement("form", { onSubmit: applyFilters, className: "mt-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-end justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Browse library"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Search and filter shared assets by visibility and category.")), /* @__PURE__ */ React.createElement("button", { type: "submit", className: "rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white" }, "Apply filters")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-4 lg:grid-cols-3" }, /* @__PURE__ */ React.createElement("input", { value: filters.data.q, onChange: (event) => filters.setData("q", event.target.value), placeholder: "Search title, description, or filename", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.data.category, onChange: (val) => filters.setData("category", val), options: [{ value: "all", label: "All categories" }, ...props.categoryOptions || []], searchable: false }), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.data.bucket, onChange: (val) => filters.setData("bucket", val), options: [{ value: "all", label: "All visibility levels" }, ...(props.listing?.bucket_options || []).filter((option) => option.value !== "all")], searchable: false }))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 lg:grid-cols-2" }, items.length > 0 ? items.map((asset) => /* @__PURE__ */ React.createElement("div", { key: asset.id, className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, asset.title), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-xs uppercase tracking-[0.16em] text-slate-500" }, asset.category, " • ", asset.visibility, " • ", asset.status)), /* @__PURE__ */ React.createElement("a", { href: asset.download_url, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-sm font-semibold text-white" }, "Download")), asset.description ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-slate-400" }, asset.description) : null, props.updatePattern ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => At.patch(props.updatePattern.replace("__ASSET__", String(asset.id)), { title: asset.title, description: asset.description || "", category: asset.category, visibility: asset.visibility, status: asset.status === "active" ? "archived" : "active", linked_project_id: asset.linked_project?.id || "", is_featured: asset.is_featured }), className: "mt-4 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, asset.status === "active" ? "Archive" : "Reactivate") : null)) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400" }, "No assets yet."))); } -const __vite_glob_0_108 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_120 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupAssets }, Symbol.toStringTag, { value: "Module" })); @@ -100313,7 +103541,7 @@ function StudioGroupChallengeEditor() { attachForm.post(props.attachArtworkUrl, { preserveScroll: true }); }, className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Attach artwork"), /* @__PURE__ */ React.createElement(NovaSelect, { value: String(attachForm.data.artwork_id || ""), onChange: (val) => attachForm.setData("artwork_id", val), placeholder: "Choose artwork", options: (props.artworkOptions || []).map((o) => ({ value: String(o.id), label: o.title })) }), /* @__PURE__ */ React.createElement("button", { type: "submit", className: "mt-4 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white" }, "Attach")) : null))); } -const __vite_glob_0_109 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_121 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupChallengeEditor }, Symbol.toStringTag, { value: "Module" })); @@ -100322,7 +103550,7 @@ function StudioGroupChallenges() { const items = Array.isArray(props.listing?.items) ? props.listing.items : []; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-400" }, "Challenges keep the group active between releases and give members a focused creative prompt."), props.createUrl ? /* @__PURE__ */ React.createElement("a", { href: props.createUrl, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Create challenge") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 lg:grid-cols-2" }, items.length > 0 ? items.map((challenge) => /* @__PURE__ */ React.createElement("a", { key: challenge.id, href: challenge.urls?.edit || challenge.url, className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-5 transition hover:border-white/20" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, challenge.title), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, challenge.status)), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-slate-400" }, challenge.summary || "Challenge page"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 text-xs text-slate-500" }, challenge.entry_count || 0, " linked entries"))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400" }, "No challenges yet."))); } -const __vite_glob_0_110 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_122 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupChallenges }, Symbol.toStringTag, { value: "Module" })); @@ -100330,7 +103558,7 @@ function StudioGroupCollections() { const { props } = X$1(); return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("div", { className: "mb-6 rounded-[28px] border border-sky-300/20 bg-sky-300/10 p-5 text-sky-100" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em]" }, "Shared curation"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-xl font-semibold" }, "Create collections for ", props.studioGroup?.name), /* @__PURE__ */ React.createElement("a", { href: props.createUrl, className: "mt-4 inline-flex rounded-full border border-sky-200/20 bg-sky-200/10 px-4 py-2 text-sm font-semibold text-sky-50" }, "New group collection")), /* @__PURE__ */ React.createElement(StudioContentBrowser, { listing: props.listing, quickCreate: [{ key: "collections", label: "Collection", icon: "fa-solid fa-layer-group", url: props.createUrl }], hideModuleFilter: true })); } -const __vite_glob_0_111 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_123 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupCollections }, Symbol.toStringTag, { value: "Module" })); @@ -100444,7 +103672,7 @@ function StudioGroupCreate() { } )), /* @__PURE__ */ React.createElement("section", { className: "mx-auto max-w-3xl rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5" }, /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Name"), /* @__PURE__ */ React.createElement("input", { value: form.name, onChange: handleNameChange, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Slug"), /* @__PURE__ */ React.createElement("input", { value: form.slug, onChange: handleSlugChange, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Short description"), /* @__PURE__ */ React.createElement("input", { value: form.headline, onChange: (event) => setForm((current) => ({ ...current, headline: event.target.value })), className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "About"), /* @__PURE__ */ React.createElement("textarea", { value: form.bio, onChange: (event) => setForm((current) => ({ ...current, bio: event.target.value })), rows: 6, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Type / category"), /* @__PURE__ */ React.createElement("input", { value: form.type, onChange: (event) => setForm((current) => ({ ...current, type: event.target.value })), className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Founded date"), /* @__PURE__ */ React.createElement(DateTimePicker, { value: form.founded_at, onChange: (nextValue) => setForm((current) => ({ ...current, founded_at: nextValue })), mode: "date", placeholder: "Pick the founding date", clearable: true, className: "bg-black/20" }))), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Website"), /* @__PURE__ */ React.createElement("input", { value: form.website_url, onChange: (event) => setForm((current) => ({ ...current, website_url: event.target.value })), className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", { className: "text-sm font-semibold text-white" }, "Avatar / logo"), /* @__PURE__ */ React.createElement("div", { className: "flex h-28 w-28 items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]" }, resolvedAvatarPreview ? /* @__PURE__ */ React.createElement("img", { src: resolvedAvatarPreview, alt: "Avatar preview", className: "h-full w-full object-cover" }) : /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-image text-slate-500" })), /* @__PURE__ */ React.createElement("input", { ref: avatarInputRef, type: "file", accept: "image/png,image/jpeg,image/webp", onChange: handleFileSelected("avatar_file", setAvatarPreview), className: "hidden" }), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => avatarInputRef.current?.click(), className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Upload avatar"), form.avatar_file ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => clearSelectedFile("avatar_file", setAvatarPreview, avatarInputRef), className: "rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-300" }, "Use URL instead") : null), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Or paste an image URL"), /* @__PURE__ */ React.createElement("input", { value: form.avatar_path, onChange: (event) => setForm((current) => ({ ...current, avatar_path: event.target.value })), placeholder: "https://", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", { className: "text-sm font-semibold text-white" }, "Cover image"), /* @__PURE__ */ React.createElement("div", { className: "flex h-28 w-full items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]" }, resolvedBannerPreview ? /* @__PURE__ */ React.createElement("img", { src: resolvedBannerPreview, alt: "Cover preview", className: "h-full w-full object-cover" }) : /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-panorama text-slate-500" })), /* @__PURE__ */ React.createElement("input", { ref: bannerInputRef, type: "file", accept: "image/png,image/jpeg,image/webp", onChange: handleFileSelected("banner_file", setBannerPreview), className: "hidden" }), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => bannerInputRef.current?.click(), className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Upload cover"), form.banner_file ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => clearSelectedFile("banner_file", setBannerPreview, bannerInputRef), className: "rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-300" }, "Use URL instead") : null), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Or paste an image URL"), /* @__PURE__ */ React.createElement("input", { value: form.banner_path, onChange: (event) => setForm((current) => ({ ...current, banner_path: event.target.value })), placeholder: "https://", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Visibility"), /* @__PURE__ */ React.createElement(NovaSelect, { value: form.visibility, onChange: (val) => setForm((current) => ({ ...current, visibility: val })), options: props.visibilityOptions || [], searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Membership policy"), /* @__PURE__ */ React.createElement(NovaSelect, { value: form.membership_policy, onChange: (val) => setForm((current) => ({ ...current, membership_policy: val })), options: props.membershipPolicyOptions || [], searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("span", { className: "text-sm text-slate-200" }, "Links"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: addLink, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white" }, "Add link")), form.links_json.map((item, index2) => /* @__PURE__ */ React.createElement("div", { key: `link-${index2}`, className: "grid gap-3 md:grid-cols-[0.8fr_1.2fr_auto]" }, /* @__PURE__ */ React.createElement("input", { value: item.label, onChange: (event) => updateLink(index2, "label", event.target.value), placeholder: "Label", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("input", { value: item.url, onChange: (event) => updateLink(index2, "url", event.target.value), placeholder: "https://", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => removeLink(index2), className: "rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100" }, "Remove")))), /* @__PURE__ */ React.createElement("div", { className: "flex justify-end gap-3" }, /* @__PURE__ */ React.createElement("a", { href: "/studio/groups", className: "rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-semibold text-white" }, "Cancel"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: submit, className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100" }, "Create group"))))); } -const __vite_glob_0_112 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_124 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupCreate }, Symbol.toStringTag, { value: "Module" })); @@ -100509,7 +103737,7 @@ function StudioGroupDashboard() { }; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-3 xl:grid-cols-6" }, /* @__PURE__ */ React.createElement(StatCard, { label: "Artworks", value: group?.counts?.artworks, icon: "fa-solid fa-images" }), /* @__PURE__ */ React.createElement(StatCard, { label: "Collections", value: group?.counts?.collections, icon: "fa-solid fa-layer-group" }), /* @__PURE__ */ React.createElement(StatCard, { label: "Followers", value: group?.counts?.followers, icon: "fa-solid fa-user-group" }), /* @__PURE__ */ React.createElement(StatCard, { label: "Active members", value: dashboard?.active_members_count || group?.counts?.members, icon: "fa-solid fa-people-group" }), /* @__PURE__ */ React.createElement(StatCard, { label: "Projects", value: dashboard?.projects_count, icon: "fa-solid fa-diagram-project" }), /* @__PURE__ */ React.createElement(StatCard, { label: "Releases", value: dashboard?.published_releases_count || dashboard?.releases_count, icon: "fa-solid fa-rocket" }), /* @__PURE__ */ React.createElement(StatCard, { label: "Assets", value: dashboard?.assets_count, icon: "fa-solid fa-box-archive" })), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Quick actions"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Run the most common group tasks without leaving the dashboard."))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2" }, quickActions.map((action) => /* @__PURE__ */ React.createElement("a", { key: action.label, href: action.href, className: `rounded-[24px] border px-4 py-4 transition hover:translate-y-[-1px] hover:border-white/20 ${toneClasses2[action.tone] || toneClasses2.sky}` }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3" }, /* @__PURE__ */ React.createElement("span", { className: "inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-current/20 bg-black/10" }, /* @__PURE__ */ React.createElement("i", { className: action.icon })), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold" }, action.label), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs opacity-80" }, action.detail)))))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h3", { className: "text-lg font-semibold text-white" }, "Pending action"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Drafts and scheduled items that still need a publishing decision.")), /* @__PURE__ */ React.createElement("div", { className: "text-right text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", null, Number(dashboard?.draft_artworks_count || 0), " drafts"), /* @__PURE__ */ React.createElement("div", null, Number(dashboard?.scheduled_artworks_count || 0), " scheduled"))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2" }, draftsPendingAction.length > 0 ? draftsPendingAction.map((artwork) => /* @__PURE__ */ React.createElement(ContentCard, { key: artwork.id, item: artwork, fallbackLabel: "Draft" })) : /* @__PURE__ */ React.createElement(EmptyCard, { title: "No drafts waiting", description: "This group has no draft artworks waiting for review or completion right now." }))), pendingJoinRequests.length > 0 ? /* @__PURE__ */ React.createElement("div", { className: "mt-6 rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h3", { className: "text-lg font-semibold text-white" }, "Pending join requests"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Applicants waiting for a review decision.")), group?.urls?.studio_join_requests ? /* @__PURE__ */ React.createElement("a", { href: group.urls.studio_join_requests, className: "text-sm font-semibold text-sky-200" }, "Open queue") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, pendingJoinRequests.map((item) => /* @__PURE__ */ React.createElement("div", { key: item.id, className: "rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "font-semibold text-white" }, item.user?.name || item.user?.username), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-sm text-slate-400" }, item.desired_role_label || item.desired_role || "Contributor", " • ", item.created_at ? new Date(item.created_at).toLocaleDateString() : "New"))))) : null), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Members"), /* @__PURE__ */ React.createElement("a", { href: group?.urls?.studio_members, className: "text-sm font-semibold text-sky-200" }, "Manage")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-2 sm:grid-cols-2" }, Object.entries(roleSummary).map(([role, count]) => /* @__PURE__ */ React.createElement("div", { key: role, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, role), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xl font-semibold text-white" }, Number(count))))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, members.slice(0, 6).map((member) => /* @__PURE__ */ React.createElement("div", { key: member.id, className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, member.user?.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: member.user.avatar_url, alt: member.user.name || member.user.username, className: "h-11 w-11 rounded-2xl object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-user" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "truncate font-semibold text-white" }, member.user?.name || member.user?.username), /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.16em] text-slate-400" }, member.role))))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Recruitment"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-lg font-semibold text-white" }, recruitment?.is_recruiting ? recruitment.headline || "Recruiting is active" : "Recruitment is off"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-400" }, recruitment?.description || "Set open roles, skills, and contact instructions from the recruitment page.")))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-6 xl:grid-cols-2" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Releases"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Track featured drops and current release pipelines.")), group?.urls?.studio_releases ? /* @__PURE__ */ React.createElement("a", { href: group.urls.studio_releases, className: "text-sm font-semibold text-sky-200" }, "Manage") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2" }, recentReleases.length > 0 ? recentReleases.map((release) => /* @__PURE__ */ React.createElement(ContentCard, { key: release.id, item: release, fallbackLabel: "Release" })) : /* @__PURE__ */ React.createElement(EmptyCard, { title: "No releases yet", description: "Create a release to track milestones, contributors, and publication status." }))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Projects"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Recent structured releases and collaboration hubs.")), group?.urls?.studio_projects ? /* @__PURE__ */ React.createElement("a", { href: group.urls.studio_projects, className: "text-sm font-semibold text-sky-200" }, "Manage") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2" }, recentProjects.length > 0 ? recentProjects.map((project) => /* @__PURE__ */ React.createElement(ContentCard, { key: project.id, item: project, fallbackLabel: "Project" })) : /* @__PURE__ */ React.createElement(EmptyCard, { title: "No projects yet", description: "Create a project to bundle shared assets, linked artworks, and a release state." }))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Challenges"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Current creative prompts and challenge arcs.")), group?.urls?.studio_challenges ? /* @__PURE__ */ React.createElement("a", { href: group.urls.studio_challenges, className: "text-sm font-semibold text-sky-200" }, "Manage") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2" }, recentChallenges.length > 0 ? recentChallenges.map((challenge) => /* @__PURE__ */ React.createElement(ContentCard, { key: challenge.id, item: challenge, fallbackLabel: "Challenge" })) : /* @__PURE__ */ React.createElement(EmptyCard, { title: "No challenges yet", description: "Launch a challenge to keep the group active between major releases." })))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-6 xl:grid-cols-2" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Trust summary"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Public-facing trust labels and internal contributor health snapshot.")), group?.urls?.studio_reputation ? /* @__PURE__ */ React.createElement("a", { href: group.urls.studio_reputation, className: "text-sm font-semibold text-sky-200" }, "Open dashboard") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, trustSignals.map((signal) => /* @__PURE__ */ React.createElement("span", { key: signal.key, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white" }, signal.label))), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-3 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Contributors"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-2xl font-semibold text-white" }, Number(reputationSummary?.counts?.contributors || 0))), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Group badges"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-2xl font-semibold text-white" }, Number(reputationSummary?.counts?.group_badges || 0))))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Contributor highlights"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Recent high-trust contributors and badge unlocks."))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, Array.isArray(reputationSummary?.top_contributors) && reputationSummary.top_contributors.length > 0 ? reputationSummary.top_contributors.slice(0, 4).map((entry) => /* @__PURE__ */ React.createElement("div", { key: entry.user?.id, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "font-semibold text-white" }, entry.user?.name || entry.user?.username), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-sm text-slate-400" }, entry.summary || "Contributor"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-xs text-slate-500" }, entry.counts?.releases || 0, " releases • ", entry.counts?.credited_artworks || 0, " artworks"))) : /* @__PURE__ */ React.createElement(EmptyCard, { title: "No contributor signals yet", description: "Release and milestone activity will populate contributor reputation here." })))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-6 xl:grid-cols-2" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Recent artworks"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Latest published work released under this group identity.")), /* @__PURE__ */ React.createElement("a", { href: group?.urls?.studio_artworks, className: "text-sm font-semibold text-sky-200" }, "View all")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2" }, recentArtworks.length > 0 ? recentArtworks.map((artwork) => /* @__PURE__ */ React.createElement(ContentCard, { key: artwork.id, item: artwork, fallbackLabel: "Published" })) : /* @__PURE__ */ React.createElement(EmptyCard, { title: "No published artworks yet", description: "Publish the first group artwork to start building this feed." }))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Events"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Upcoming or recently updated moments on the group timeline.")), group?.urls?.studio_events ? /* @__PURE__ */ React.createElement("a", { href: group.urls.studio_events, className: "text-sm font-semibold text-sky-200" }, "Manage") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2" }, recentEvents.length > 0 ? recentEvents.map((event) => /* @__PURE__ */ React.createElement(ContentCard, { key: event.id, item: event, fallbackLabel: "Event" })) : /* @__PURE__ */ React.createElement(EmptyCard, { title: "No events yet", description: "Schedule a launch, stream, or milestone to start the group timeline." })))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-6 xl:grid-cols-2" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Recent collections"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Collections most recently updated in this group workspace.")), /* @__PURE__ */ React.createElement("a", { href: group?.urls?.studio_collections, className: "text-sm font-semibold text-sky-200" }, "View all")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2" }, recentCollections.length > 0 ? recentCollections.map((collection) => /* @__PURE__ */ React.createElement(ContentCard, { key: collection.id, item: collection, fallbackLabel: "Collection" })) : /* @__PURE__ */ React.createElement(EmptyCard, { title: "No collections yet", description: "Create a collection to organize group work into campaigns, series, or themed sets." }))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Activity feed"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Pinned and recent internal or public timeline items.")), group?.urls?.studio_activity ? /* @__PURE__ */ React.createElement("a", { href: group.urls.studio_activity, className: "text-sm font-semibold text-sky-200" }, "Open feed") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, recentActivity.length > 0 ? recentActivity.map((item) => /* @__PURE__ */ React.createElement(ActivityCard, { key: item.id, item })) : /* @__PURE__ */ React.createElement(EmptyCard, { title: "No activity items yet", description: "Publishing projects, events, posts, and member milestones will populate this feed." })))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-6 xl:grid-cols-2" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Review queue"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Latest artwork submissions waiting for moderation.")), group?.urls?.studio_review ? /* @__PURE__ */ React.createElement("a", { href: group.urls.studio_review, className: "text-sm font-semibold text-sky-200" }, "Open queue") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2" }, reviewQueuePreview.length > 0 ? reviewQueuePreview.map((item) => /* @__PURE__ */ React.createElement("a", { key: item.id, href: item.urls?.edit, className: "rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20" }, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs uppercase tracking-[0.16em] text-slate-500" }, item.group_review_status))) : /* @__PURE__ */ React.createElement(EmptyCard, { title: "No pending reviews", description: "Contributor submissions will appear here when they are sent for review." }))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Recent posts"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Announcements and updates published from the group.")), group?.urls?.studio_posts ? /* @__PURE__ */ React.createElement("a", { href: group.urls.studio_posts, className: "text-sm font-semibold text-sky-200" }, "Manage posts") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2" }, recentPosts.length > 0 ? recentPosts.map((post2) => /* @__PURE__ */ React.createElement("a", { key: post2.id, href: post2.url, className: "rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, post2.type), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-base font-semibold text-white" }, post2.title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-400" }, post2.excerpt || "Open post"))) : /* @__PURE__ */ React.createElement(EmptyCard, { title: "No posts yet", description: "Create the first group announcement to add a public news feed." })))), /* @__PURE__ */ React.createElement("section", { className: "mt-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Recent history"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3" }, recentHistory.length > 0 ? recentHistory.map((item) => /* @__PURE__ */ React.createElement("div", { key: item.id, className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold text-white" }, item.summary || item.action_type), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-xs text-slate-400" }, item.actor?.name || item.actor?.username || "System", " • ", item.created_at ? new Date(item.created_at).toLocaleString() : "Recently"))) : /* @__PURE__ */ React.createElement(EmptyCard, { title: "No history yet", description: "Audit events will appear here as members review requests, posts, and submissions." })))); } -const __vite_glob_0_113 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_125 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupDashboard }, Symbol.toStringTag, { value: "Module" })); @@ -100548,7 +103776,7 @@ function StudioGroupEventEditor() { form.post(props.publishUrl, { preserveScroll: true }); }, className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("button", { type: "submit", className: "rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-sm font-semibold text-white" }, "Publish event")) : null)); } -const __vite_glob_0_114 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_126 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupEventEditor }, Symbol.toStringTag, { value: "Module" })); @@ -100557,7 +103785,7 @@ function StudioGroupEvents() { const items = Array.isArray(props.listing?.items) ? props.listing.items : []; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-400" }, "Events let the group announce launches, sessions, milestones, and time-based updates."), props.createUrl ? /* @__PURE__ */ React.createElement("a", { href: props.createUrl, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Create event") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 lg:grid-cols-2" }, items.length > 0 ? items.map((event) => /* @__PURE__ */ React.createElement("a", { key: event.id, href: event.urls?.edit || event.url, className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-5 transition hover:border-white/20" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, event.title), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, event.status)), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-slate-400" }, event.summary || "Event page"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 text-xs text-slate-500" }, event.start_at ? new Date(event.start_at).toLocaleString() : "Unscheduled", " • ", event.event_type))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400" }, "No events yet."))); } -const __vite_glob_0_115 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_127 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupEvents }, Symbol.toStringTag, { value: "Module" })); @@ -100584,7 +103812,7 @@ function StudioGroupInvitations() { ); return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("section", { className: "mb-6 rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/75" }, "Group invitations"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-xl font-semibold text-white" }, "Invite collaborators into ", props.studioGroup?.name), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-2xl text-sm leading-6 text-slate-300" }, "Pending invites stay separate from active members here, so owners and admins can review who was invited, when the invite expires, and revoke access before acceptance.")), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement(xe, { href: props.studioGroup?.urls?.studio_members, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Members"), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100" }, pendingInvites.length, " pending"))), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-3 md:grid-cols-[1.1fr_0.8fr_1fr_0.7fr_auto]" }, /* @__PURE__ */ React.createElement("input", { value: invite.username, onChange: (event) => setInvite((current) => ({ ...current, username: event.target.value })), placeholder: "Username", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement(NovaSelect, { value: invite.role, onChange: (val) => setInvite((current) => ({ ...current, role: val })), searchable: false, options: [{ value: "contributor", label: "Contributor" }, { value: "editor", label: "Editor" }, { value: "admin", label: "Admin" }] }), /* @__PURE__ */ React.createElement("input", { value: invite.note, onChange: (event) => setInvite((current) => ({ ...current, note: event.target.value })), placeholder: "Optional note", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("input", { value: invite.expires_in_days, onChange: (event) => setInvite((current) => ({ ...current, expires_in_days: event.target.value })), type: "number", min: "1", max: "30", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => At.post(props.endpoints?.invite, { ...invite, expires_in_days: Number(invite.expires_in_days || 7) || 7 }), className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100" }, "Send invite"))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Pending invitations"), /* @__PURE__ */ React.createElement("span", { className: "text-sm text-slate-400" }, pendingInvites.length, " outstanding")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, pendingInvites.length > 0 ? pendingInvites.map((inviteRow) => /* @__PURE__ */ React.createElement("article", { key: inviteRow.id, className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-col gap-4 md:flex-row md:items-center" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3" }, inviteRow.user?.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: inviteRow.user.avatar_url, alt: inviteRow.user.name || inviteRow.user.username, className: "h-12 w-12 rounded-2xl object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-user" })), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "font-semibold text-white" }, inviteRow.user?.name || inviteRow.user?.username), /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.16em] text-slate-400" }, inviteRow.role_label || inviteRow.role))), /* @__PURE__ */ React.createElement("div", { className: "md:ml-auto flex flex-wrap items-center gap-3 text-xs text-slate-400" }, inviteRow.invited_by ? /* @__PURE__ */ React.createElement("span", null, "Invited by ", inviteRow.invited_by.name || inviteRow.invited_by.username) : null, inviteRow.invited_at ? /* @__PURE__ */ React.createElement("span", null, "Sent ", formatInviteTimestamp(inviteRow.invited_at)) : null, inviteRow.expires_at ? /* @__PURE__ */ React.createElement("span", null, "Expires ", formatInviteTimestamp(inviteRow.expires_at)) : null)), inviteRow.note ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm text-slate-300" }, inviteRow.note) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, inviteRow.can_revoke && inviteRow.revoke_url ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => At.delete(inviteRow.revoke_url), className: "rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-2 text-sm font-semibold text-rose-100" }, "Revoke invite") : null))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 px-6 py-12 text-center text-slate-400" }, "No pending invites for this group."))), /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Recent invite history"), /* @__PURE__ */ React.createElement("span", { className: "text-sm text-slate-400" }, revokedInvites.length, " revoked or expired")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, revokedInvites.length > 0 ? revokedInvites.map((inviteRow) => /* @__PURE__ */ React.createElement("article", { key: inviteRow.id, className: "rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "font-semibold text-white" }, inviteRow.user?.name || inviteRow.user?.username), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs uppercase tracking-[0.16em] text-slate-400" }, inviteRow.is_expired ? "Expired" : "Revoked", " • ", inviteRow.role_label || inviteRow.role), inviteRow.invited_at ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-400" }, "Originally sent ", formatInviteTimestamp(inviteRow.invited_at)) : null)) : /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-dashed border-white/10 px-4 py-8 text-center text-slate-400" }, "No recent invite history yet."))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Active members"), /* @__PURE__ */ React.createElement("span", { className: "text-sm text-slate-400" }, activeMembers.length, " active")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, activeMembers.slice(0, 6).map((member) => /* @__PURE__ */ React.createElement("div", { key: member.id, className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, member.user?.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: member.user.avatar_url, alt: member.user.name || member.user.username, className: "h-11 w-11 rounded-2xl object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-user" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "truncate font-semibold text-white" }, member.user?.name || member.user?.username), /* @__PURE__ */ React.createElement("div", { className: "text-xs uppercase tracking-[0.16em] text-slate-400" }, member.role_label || member.role))))))))); } -const __vite_glob_0_116 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_128 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupInvitations }, Symbol.toStringTag, { value: "Module" })); @@ -100613,7 +103841,7 @@ function routeUrl(baseUrl, id, action) { if (!baseUrl) return ""; return `${String(baseUrl).replace(/\/$/, "")}/${id}/${action}`; } -const __vite_glob_0_117 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_129 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupJoinRequests }, Symbol.toStringTag, { value: "Module" })); @@ -100680,7 +103908,7 @@ function StudioGroupMembers() { return /* @__PURE__ */ React.createElement("div", { key: option.value, className: "rounded-2xl border border-white/10 bg-white/[0.03] p-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold text-white" }, option.label), /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => setPermissionState(member.id, option.value, "inherit"), className: `rounded-full border px-3 py-1.5 text-xs font-semibold ${current === "inherit" ? "border-white/20 bg-white/[0.08] text-white" : "border-white/10 bg-transparent text-slate-300"}` }, "Inherit"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => setPermissionState(member.id, option.value, "allow"), className: `rounded-full border px-3 py-1.5 text-xs font-semibold ${current === "allow" ? "border-emerald-300/20 bg-emerald-400/10 text-emerald-100" : "border-white/10 bg-transparent text-slate-300"}` }, "Allow"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => setPermissionState(member.id, option.value, "deny"), className: `rounded-full border px-3 py-1.5 text-xs font-semibold ${current === "deny" ? "border-rose-300/20 bg-rose-400/10 text-rose-100" : "border-white/10 bg-transparent text-slate-300"}` }, "Deny"))); }))) : null)), filteredMembers.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "px-4 py-8 text-sm text-slate-400" }, "No members match the current search.") : null)))); } -const __vite_glob_0_118 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_130 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupMembers }, Symbol.toStringTag, { value: "Module" })); @@ -100704,7 +103932,7 @@ function StudioGroupPostEditor() { }; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("form", { onSubmit: submit, className: "grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Type"), /* @__PURE__ */ React.createElement(NovaSelect, { value: form.data.type, onChange: (val) => form.setData("type", val), options: Array.isArray(props.typeOptions) ? props.typeOptions : [], searchable: false })), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Title"), /* @__PURE__ */ React.createElement("input", { value: form.data.title, onChange: (event) => form.setData("title", event.target.value), className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Excerpt"), /* @__PURE__ */ React.createElement("textarea", { value: form.data.excerpt, onChange: (event) => form.setData("excerpt", event.target.value), rows: 3, className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Content"), /* @__PURE__ */ React.createElement("textarea", { value: form.data.content, onChange: (event) => form.setData("content", event.target.value), rows: 12, className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Post controls"), /* @__PURE__ */ React.createElement("div", { className: "mt-5 space-y-3" }, /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: form.processing, className: "w-full rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-3 text-sm font-semibold text-sky-100 disabled:opacity-60" }, "Save"), props.publishUrl ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => At.post(props.publishUrl), className: "w-full rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm font-semibold text-emerald-100" }, "Publish") : null, props.pinUrl ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => At.post(props.pinUrl), className: "w-full rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100" }, "Toggle pinned") : null, props.archiveUrl ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => At.post(props.archiveUrl), className: "w-full rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100" }, "Archive") : null)))); } -const __vite_glob_0_119 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_131 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupPostEditor }, Symbol.toStringTag, { value: "Module" })); @@ -100713,7 +103941,7 @@ function StudioGroupPosts() { const items = Array.isArray(props.listing?.items) ? props.listing.items : []; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Post library"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Draft, publish, pin, and archive public group posts.")), props.createUrl ? /* @__PURE__ */ React.createElement("a", { href: props.createUrl, className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100" }, "New post") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-4 md:grid-cols-2" }, items.length > 0 ? items.map((item) => /* @__PURE__ */ React.createElement("article", { key: item.id, className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, item.type), /* @__PURE__ */ React.createElement("h3", { className: "mt-2 text-lg font-semibold text-white" }, item.title)), /* @__PURE__ */ React.createElement("div", { className: "flex flex-col items-end gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300" }, item.status), item.is_pinned ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-amber-100" }, "Pinned") : null)), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-slate-300" }, item.excerpt || item.content || "No excerpt yet."), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("a", { href: item.urls?.edit, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Edit"), item.urls?.public ? /* @__PURE__ */ React.createElement("a", { href: item.urls.public, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "View") : null))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400" }, "No posts yet.")))); } -const __vite_glob_0_120 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_132 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupPosts }, Symbol.toStringTag, { value: "Module" })); @@ -100762,7 +103990,7 @@ function StudioGroupProjectEditor() { milestoneForm.post(props.storeMilestoneUrl, { preserveScroll: true, onSuccess: () => milestoneForm.reset("title", "summary", "due_date", "owner_user_id", "notes") }); }, className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Milestones"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, /* @__PURE__ */ React.createElement("input", { value: milestoneForm.data.title, onChange: (event) => milestoneForm.setData("title", event.target.value), placeholder: "Milestone title", className: "w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("textarea", { value: milestoneForm.data.summary, onChange: (event) => milestoneForm.setData("summary", event.target.value), placeholder: "Summary", rows: 3, className: "w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: milestoneForm.data.status, onChange: (val) => milestoneForm.setData("status", val), searchable: false, options: ["pending", "active", "blocked", "completed", "cancelled"].map((s2) => ({ value: s2, label: s2 })) }), /* @__PURE__ */ React.createElement(DateTimePicker, { value: milestoneForm.data.due_date, onChange: (nextValue) => milestoneForm.setData("due_date", nextValue), mode: "date", placeholder: "Due date", clearable: true, className: "bg-black/20" })), /* @__PURE__ */ React.createElement(NovaSelect, { value: String(milestoneForm.data.owner_user_id || ""), onChange: (val) => milestoneForm.setData("owner_user_id", val), placeholder: "No owner", options: (props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username })) }), /* @__PURE__ */ React.createElement("textarea", { value: milestoneForm.data.notes, onChange: (event) => milestoneForm.setData("notes", event.target.value), placeholder: "Notes", rows: 3, className: "w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("button", { type: "submit", className: "rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white" }, "Add milestone")), Array.isArray(project?.milestones) && project.milestones.length > 0 ? /* @__PURE__ */ React.createElement("div", { className: "mt-6 space-y-3" }, project.milestones.map((milestone) => /* @__PURE__ */ React.createElement("div", { key: milestone.id, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "font-semibold text-white" }, milestone.title), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs text-slate-500" }, milestone.owner?.name || milestone.owner?.username || "No owner", milestone.due_date ? ` • due ${milestone.due_date}` : "")), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => At.patch(props.updateMilestonePattern.replace("__MILESTONE__", String(milestone.id)), { title: milestone.title, summary: milestone.summary || "", status: milestone.status === "completed" ? "active" : "completed", due_date: milestone.due_date || "", owner_user_id: milestone.owner?.id || "", notes: milestone.notes || "" }, { preserveScroll: true }), className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold text-white" }, "Mark ", milestone.status === "completed" ? "active" : "complete")), milestone.summary ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-400" }, milestone.summary) : null))) : null) : null))); } -const __vite_glob_0_121 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_133 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupProjectEditor }, Symbol.toStringTag, { value: "Module" })); @@ -100772,7 +104000,7 @@ function StudioGroupProjects() { const items = Array.isArray(listing.items) ? listing.items : []; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-400" }, "Projects give the group a structured place for releases, teams, and linked outputs."), props.createUrl ? /* @__PURE__ */ React.createElement("a", { href: props.createUrl, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Create project") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 lg:grid-cols-2" }, items.length > 0 ? items.map((project) => /* @__PURE__ */ React.createElement("a", { key: project.id, href: project.urls?.edit || project.url, className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-5 transition hover:border-white/20" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, project.title), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, project.status)), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-slate-400" }, project.summary || "Project page"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 text-xs text-slate-500" }, project.counts?.artworks || 0, " artworks • ", project.counts?.assets || 0, " assets • ", project.counts?.team || 0, " team • ", project.counts?.milestones || 0, " milestones • ", project.counts?.releases || 0, " releases"))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400" }, "No projects yet."))); } -const __vite_glob_0_122 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_134 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupProjects }, Symbol.toStringTag, { value: "Module" })); @@ -100803,7 +104031,7 @@ function StudioGroupRecruitment() { return /* @__PURE__ */ React.createElement("button", { key: option.value, type: "button", onClick: () => form.setData("skills_json", toggleItem(form.data.skills_json, option.value)), className: `rounded-full border px-3 py-1.5 text-xs font-semibold ${selected ? "border-sky-300/20 bg-sky-300/10 text-sky-100" : "border-white/10 bg-white/[0.03] text-slate-300"}` }, option.label); }))))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Application settings"), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-4" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Contact mode"), /* @__PURE__ */ React.createElement(NovaSelect, { value: form.data.contact_mode, onChange: (val) => form.setData("contact_mode", val), options: Array.isArray(props.contactModes) ? props.contactModes : [], searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Visibility"), /* @__PURE__ */ React.createElement(NovaSelect, { value: form.data.visibility, onChange: (val) => form.setData("visibility", val), options: Array.isArray(props.visibilityOptions) ? props.visibilityOptions : [], searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("p", { className: "font-semibold text-white" }, "Public preview"), /* @__PURE__ */ React.createElement("p", { className: "mt-2" }, form.data.headline || "No headline yet."), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-slate-400" }, form.data.description || "Recruitment copy will show here once you add it."), form.data.roles_json.length > 0 ? /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex flex-wrap gap-2" }, form.data.roles_json.map((role) => /* @__PURE__ */ React.createElement("span", { key: role, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-white" }, role))) : null), /* @__PURE__ */ React.createElement("button", { type: "submit", disabled: form.processing, className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-3 text-sm font-semibold text-sky-100 disabled:opacity-60" }, "Save recruitment profile"))))); } -const __vite_glob_0_123 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_135 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupRecruitment }, Symbol.toStringTag, { value: "Module" })); @@ -100856,7 +104084,7 @@ function StudioGroupReleaseEditor() { milestoneForm.post(props.storeMilestoneUrl, { preserveScroll: true, onSuccess: () => milestoneForm.reset("title", "summary", "due_date", "owner_user_id", "notes") }); }, className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Milestones"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, /* @__PURE__ */ React.createElement("input", { value: milestoneForm.data.title, onChange: (event) => milestoneForm.setData("title", event.target.value), placeholder: "Milestone title", className: "w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("textarea", { value: milestoneForm.data.summary, onChange: (event) => milestoneForm.setData("summary", event.target.value), placeholder: "Summary", rows: 3, className: "w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 md:grid-cols-2" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: milestoneForm.data.status, onChange: (val) => milestoneForm.setData("status", val), searchable: false, options: ["pending", "active", "blocked", "completed", "cancelled"].map((s2) => ({ value: s2, label: s2 })) }), /* @__PURE__ */ React.createElement(DateTimePicker, { value: milestoneForm.data.due_date, onChange: (nextValue) => milestoneForm.setData("due_date", nextValue), mode: "date", placeholder: "Due date", clearable: true, className: "bg-black/20" })), /* @__PURE__ */ React.createElement(NovaSelect, { value: String(milestoneForm.data.owner_user_id || ""), onChange: (val) => milestoneForm.setData("owner_user_id", val), placeholder: "No owner", options: (props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username })) }), /* @__PURE__ */ React.createElement("textarea", { value: milestoneForm.data.notes, onChange: (event) => milestoneForm.setData("notes", event.target.value), placeholder: "Notes", rows: 3, className: "w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("button", { type: "submit", className: "rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white" }, "Add milestone")), Array.isArray(release?.milestones) && release.milestones.length > 0 ? /* @__PURE__ */ React.createElement("div", { className: "mt-6 space-y-3" }, release.milestones.map((milestone) => /* @__PURE__ */ React.createElement("div", { key: milestone.id, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "font-semibold text-white" }, milestone.title), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs text-slate-500" }, milestone.owner?.name || milestone.owner?.username || "No owner", milestone.due_date ? ` • due ${milestone.due_date}` : "")), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => At.patch(props.updateMilestonePattern.replace("__MILESTONE__", String(milestone.id)), { title: milestone.title, summary: milestone.summary || "", status: milestone.status === "completed" ? "active" : "completed", due_date: milestone.due_date || "", owner_user_id: milestone.owner?.id || "", notes: milestone.notes || "" }, { preserveScroll: true }), className: "rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold text-white" }, "Mark ", milestone.status === "completed" ? "active" : "complete")), milestone.summary ? /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-400" }, milestone.summary) : null))) : null) : null))); } -const __vite_glob_0_124 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_136 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupReleaseEditor }, Symbol.toStringTag, { value: "Module" })); @@ -100868,7 +104096,7 @@ function StudioGroupReleases() { const currentBucket = listing.filters?.bucket || "all"; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between" }, /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-400" }, "Track the release pipeline from draft through public launch, with milestones and contributor credits."), /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3" }, /* @__PURE__ */ React.createElement(NovaSelect, { value: currentBucket, onChange: (val) => At.get(window.location.pathname, { bucket: val }, { preserveScroll: true, preserveState: true }), options: bucketOptions, searchable: false }), props.createUrl ? /* @__PURE__ */ React.createElement("a", { href: props.createUrl, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Create release") : null)), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-4 lg:grid-cols-2" }, items.length > 0 ? items.map((release) => /* @__PURE__ */ React.createElement("div", { key: release.id, className: "overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.03]" }, release.cover_url ? /* @__PURE__ */ React.createElement("img", { src: release.cover_url, alt: release.title, className: "aspect-[4/3] w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex aspect-[4/3] items-center justify-center bg-white/[0.03] text-slate-500" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-rocket text-2xl" })), /* @__PURE__ */ React.createElement("div", { className: "p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, release.status), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, release.current_stage), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, release.visibility)), /* @__PURE__ */ React.createElement("h2", { className: "mt-3 text-xl font-semibold text-white" }, release.title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, release.summary || "Release page"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 text-xs text-slate-500" }, release.counts?.artworks || 0, " artworks • ", release.counts?.contributors || 0, " contributors • ", release.counts?.milestones || 0, " milestones"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("a", { href: release.urls?.edit || release.url, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Manage"), release.urls?.public ? /* @__PURE__ */ React.createElement("a", { href: release.urls.public, className: "rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm font-semibold text-white" }, "View public") : null)))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400" }, "No releases yet."))); } -const __vite_glob_0_125 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_137 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupReleases }, Symbol.toStringTag, { value: "Module" })); @@ -100885,7 +104113,7 @@ function StudioGroupReputation() { const memberBadgeUnlocks = Array.isArray(reputation.member_badge_unlocks) ? reputation.member_badge_unlocks : []; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2 xl:grid-cols-5" }, /* @__PURE__ */ React.createElement(MetricCard, { label: "Freshness", value: metrics.freshness_score }), /* @__PURE__ */ React.createElement(MetricCard, { label: "Activity", value: metrics.activity_score }), /* @__PURE__ */ React.createElement(MetricCard, { label: "Release", value: metrics.release_score }), /* @__PURE__ */ React.createElement(MetricCard, { label: "Trust", value: metrics.trust_score }), /* @__PURE__ */ React.createElement(MetricCard, { label: "Collaboration", value: metrics.collaboration_score })), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Trust signals"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Public-safe labels that shape discovery and confidence."))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, trustSignals.map((signal) => /* @__PURE__ */ React.createElement("span", { key: signal.key, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white" }, signal.label))), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-3 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Contributors"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-2xl font-semibold text-white" }, Number(reputation.counts?.contributors || 0))), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Member badges"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-2xl font-semibold text-white" }, Number(reputation.counts?.member_badges || 0)))), metrics.last_calculated_at ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 text-xs text-slate-500" }, "Last calculated ", new Date(metrics.last_calculated_at).toLocaleString()) : null), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Top contributors"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Reputation summaries derived from visible collaboration history."))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, topContributors.length > 0 ? topContributors.map((entry) => /* @__PURE__ */ React.createElement("div", { key: entry.user?.id, className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3" }, entry.user?.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: entry.user.avatar_url, alt: entry.user?.name || entry.user?.username, className: "h-11 w-11 rounded-2xl object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-user" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("div", { className: "truncate font-semibold text-white" }, entry.user?.name || entry.user?.username), entry.trusted_indicator ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-emerald-300/20 bg-emerald-300/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100" }, "Trusted") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-sm text-slate-400" }, entry.summary || "Contributor"))), /* @__PURE__ */ React.createElement("div", { className: "mt-3 text-xs text-slate-500" }, entry.counts?.releases || 0, " releases • ", entry.counts?.projects || 0, " projects • ", entry.counts?.credited_artworks || 0, " artworks • ", entry.counts?.review_actions || 0, " reviews"), Array.isArray(entry.badges) && entry.badges.length > 0 ? /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex flex-wrap gap-2" }, entry.badges.map((badge) => /* @__PURE__ */ React.createElement("span", { key: `${entry.user?.id}-${badge.key}`, className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300" }, badge.label))) : null)) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400" }, "No contributor reputation signals yet.")))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-6 xl:grid-cols-2" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Group badges"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, recentBadges.length > 0 ? recentBadges.map((badge) => /* @__PURE__ */ React.createElement("div", { key: badge.key, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "font-semibold text-white" }, badge.label), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-sm text-slate-400" }, badge.reason))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400" }, "No group badges awarded yet."))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Recent member badge unlocks"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, memberBadgeUnlocks.length > 0 ? memberBadgeUnlocks.map((entry) => /* @__PURE__ */ React.createElement("div", { key: `${entry.user?.id}-${entry.badge?.key}`, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "font-semibold text-white" }, entry.user?.name || entry.user?.username), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-sm text-sky-200" }, entry.badge?.label), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-sm text-slate-400" }, entry.badge?.reason))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400" }, "No member badge unlocks yet."))))); } -const __vite_glob_0_126 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_138 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupReputation }, Symbol.toStringTag, { value: "Module" })); @@ -100902,7 +104130,7 @@ function StudioGroupReviewQueue() { }; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Submission queue"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Review artwork drafts before they publish under the group identity.")), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300" }, listing.filters?.bucket || "submitted")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-4" }, items.length > 0 ? items.map((item) => /* @__PURE__ */ React.createElement("article", { key: item.id, className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-start gap-4" }, item.thumb ? /* @__PURE__ */ React.createElement("img", { src: item.thumb, alt: item.title, className: "h-24 w-24 rounded-2xl object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-24 w-24 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-image" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("h3", { className: "text-lg font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300" }, item.group_review_status)), /* @__PURE__ */ React.createElement("div", { className: "mt-2 flex flex-wrap gap-3 text-xs text-slate-400" }, item.primary_author ? /* @__PURE__ */ React.createElement("span", null, "Author: ", item.primary_author.name || item.primary_author.username) : null, item.uploader ? /* @__PURE__ */ React.createElement("span", null, "Uploader: ", item.uploader.name || item.uploader.username) : null, item.submitted_at ? /* @__PURE__ */ React.createElement("span", null, "Submitted ", new Date(item.submitted_at).toLocaleString()) : null), item.group_review_notes ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-300" }, item.group_review_notes) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("a", { href: item.urls?.edit, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Open draft"), item.can_review ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => sendAction(item, "approve"), className: "rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100" }, "Approve") : null, item.can_review ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => sendAction(item, "needs_changes"), className: "rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-sm font-semibold text-amber-100" }, "Needs changes") : null, item.can_review ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => sendAction(item, "reject"), className: "rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100" }, "Reject") : null))))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400" }, "No submissions in this bucket."))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("h2", { className: "text-xl font-semibold text-white" }, "Recent history"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, (Array.isArray(props.recentHistory) ? props.recentHistory : []).map((item) => /* @__PURE__ */ React.createElement("div", { key: item.id, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold text-white" }, item.summary || item.action_type), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs text-slate-400" }, item.actor?.name || item.actor?.username || "System", " • ", item.created_at ? new Date(item.created_at).toLocaleString() : "Recently"))))))); } -const __vite_glob_0_127 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_139 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupReviewQueue }, Symbol.toStringTag, { value: "Module" })); @@ -100995,7 +104223,7 @@ function StudioGroupSettings() { }; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("section", { className: "mx-auto max-w-3xl rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5" }, /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Name"), /* @__PURE__ */ React.createElement("input", { value: form.name, onChange: (event) => setForm((current) => ({ ...current, name: event.target.value })), className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Slug"), /* @__PURE__ */ React.createElement("input", { value: form.slug, onChange: (event) => setForm((current) => ({ ...current, slug: event.target.value })), className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Short description"), /* @__PURE__ */ React.createElement("input", { value: form.headline, onChange: (event) => setForm((current) => ({ ...current, headline: event.target.value })), className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "About"), /* @__PURE__ */ React.createElement("textarea", { value: form.bio, onChange: (event) => setForm((current) => ({ ...current, bio: event.target.value })), rows: 6, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Type / category"), /* @__PURE__ */ React.createElement("input", { value: form.type, onChange: (event) => setForm((current) => ({ ...current, type: event.target.value })), className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Founded date"), /* @__PURE__ */ React.createElement(DateTimePicker, { value: form.founded_at, onChange: (nextValue) => setForm((current) => ({ ...current, founded_at: nextValue })), mode: "date", placeholder: "Pick the founding date", clearable: true, className: "bg-black/20" }))), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Website"), /* @__PURE__ */ React.createElement("input", { value: form.website_url, onChange: (event) => setForm((current) => ({ ...current, website_url: event.target.value })), className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", { className: "text-sm font-semibold text-white" }, "Avatar / logo"), /* @__PURE__ */ React.createElement("div", { className: "flex h-28 w-28 items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]" }, resolvedAvatarPreview ? /* @__PURE__ */ React.createElement("img", { src: resolvedAvatarPreview, alt: "Avatar preview", className: "h-full w-full object-cover" }) : /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-image text-slate-500" })), /* @__PURE__ */ React.createElement("input", { ref: avatarInputRef, type: "file", accept: "image/png,image/jpeg,image/webp", onChange: handleFileSelected("avatar_file", setAvatarPreview), className: "hidden" }), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => avatarInputRef.current?.click(), className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Upload avatar"), form.avatar_file ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => clearSelectedFile("avatar_file", setAvatarPreview, avatarInputRef), className: "rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-300" }, "Use current path") : null), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Or paste an image URL"), /* @__PURE__ */ React.createElement("input", { value: form.avatar_path, onChange: (event) => setForm((current) => ({ ...current, avatar_path: event.target.value })), placeholder: "https://", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", { className: "text-sm font-semibold text-white" }, "Cover image"), /* @__PURE__ */ React.createElement("div", { className: "flex h-28 w-full items-center justify-center overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04]" }, resolvedBannerPreview ? /* @__PURE__ */ React.createElement("img", { src: resolvedBannerPreview, alt: "Cover preview", className: "h-full w-full object-cover" }) : /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-panorama text-slate-500" })), /* @__PURE__ */ React.createElement("input", { ref: bannerInputRef, type: "file", accept: "image/png,image/jpeg,image/webp", onChange: handleFileSelected("banner_file", setBannerPreview), className: "hidden" }), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => bannerInputRef.current?.click(), className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, "Upload cover"), form.banner_file ? /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => clearSelectedFile("banner_file", setBannerPreview, bannerInputRef), className: "rounded-full border border-white/10 bg-transparent px-4 py-2 text-sm font-semibold text-slate-300" }, "Use current path") : null), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Or paste an image URL"), /* @__PURE__ */ React.createElement("input", { value: form.banner_path, onChange: (event) => setForm((current) => ({ ...current, banner_path: event.target.value })), placeholder: "https://", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Featured artwork"), /* @__PURE__ */ React.createElement(NovaSelect, { value: String(form.featured_artwork_id || ""), onChange: (val) => setForm((current) => ({ ...current, featured_artwork_id: val })), placeholder: "Use latest published artwork", options: featuredArtworkOptions.map((item) => ({ value: String(item.id), label: item.title })) })), selectedFeaturedArtwork ? /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 rounded-[20px] border border-white/10 bg-white/[0.04] p-3" }, selectedFeaturedArtwork.thumb ? /* @__PURE__ */ React.createElement("img", { src: selectedFeaturedArtwork.thumb, alt: selectedFeaturedArtwork.title, className: "h-16 w-16 rounded-2xl object-cover" }) : null, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "font-semibold text-white" }, selectedFeaturedArtwork.title), /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-400" }, selectedFeaturedArtwork.author || "Group member"))) : /* @__PURE__ */ React.createElement("p", { className: "text-sm text-slate-400" }, "When this is empty, the public overview falls back to the latest published works automatically.")), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Visibility"), /* @__PURE__ */ React.createElement(NovaSelect, { value: form.visibility, onChange: (val) => setForm((current) => ({ ...current, visibility: val })), options: props.visibilityOptions || [], searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-200" }, /* @__PURE__ */ React.createElement("span", null, "Membership policy"), /* @__PURE__ */ React.createElement(NovaSelect, { value: form.membership_policy, onChange: (val) => setForm((current) => ({ ...current, membership_policy: val })), options: props.membershipPolicyOptions || [], searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("span", { className: "text-sm text-slate-200" }, "Links"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: addLink, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white" }, "Add link")), form.links_json.map((item, index2) => /* @__PURE__ */ React.createElement("div", { key: `link-${index2}`, className: "grid gap-3 md:grid-cols-[0.8fr_1.2fr_auto]" }, /* @__PURE__ */ React.createElement("input", { value: item.label, onChange: (event) => updateLink(index2, "label", event.target.value), placeholder: "Label", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("input", { value: item.url, onChange: (event) => updateLink(index2, "url", event.target.value), placeholder: "https://", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => removeLink(index2), className: "rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100" }, "Remove")))), /* @__PURE__ */ React.createElement("div", { className: "flex justify-between gap-3" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: archiveGroup, className: "rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100" }, "Archive group"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: submit, className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100" }, "Save settings"))))); } -const __vite_glob_0_128 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_140 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupSettings }, Symbol.toStringTag, { value: "Module" })); @@ -101023,7 +104251,7 @@ function StudioGroupsIndex() { } ), /* @__PURE__ */ React.createElement("div", { className: "mb-6 flex items-center justify-between gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80" }, "Collective publishing"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Launch and manage shared identities")), /* @__PURE__ */ React.createElement(xe, { href: props.endpoints?.create, className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15" }, "Create group")), pendingInvites.length > 0 ? /* @__PURE__ */ React.createElement("section", { className: "mb-6 rounded-[28px] border border-amber-300/20 bg-amber-400/10 p-5" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-amber-50" }, "Pending invites"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2" }, pendingInvites.map((invite) => /* @__PURE__ */ React.createElement("article", { key: invite.id, className: "rounded-2xl border border-white/10 bg-black/20 p-4 text-white" }, /* @__PURE__ */ React.createElement("h3", { className: "text-base font-semibold" }, invite.group?.name), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-amber-50/80" }, "Role: ", invite.role), invite.invited_by ? /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-amber-50/70" }, "Invited by ", invite.invited_by.name || invite.invited_by.username) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => At.post(invite.accept_url), className: "rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-2 text-sm font-semibold text-emerald-100" }, "Accept"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => At.post(invite.decline_url), className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-sm font-semibold text-white" }, "Decline")))))) : null, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 xl:grid-cols-2" }, groups.length > 0 ? groups.map((group) => /* @__PURE__ */ React.createElement(GroupCard, { key: group.slug, group })) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-dashed border-white/10 px-6 py-16 text-center text-slate-400" }, "No groups yet. Create one to start publishing collaboratively."))); } -const __vite_glob_0_129 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_141 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGroupsIndex }, Symbol.toStringTag, { value: "Module" })); @@ -101124,7 +104352,7 @@ function StudioGrowth() { /* @__PURE__ */ React.createElement("div", { className: "mt-3 grid grid-cols-3 gap-3 text-xs text-slate-400" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", null, "Views"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-sm font-semibold text-white" }, Number(item.metrics?.views || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", null, "Reactions"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-sm font-semibold text-white" }, Number(item.metrics?.appreciation || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", null, "Comments"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-sm font-semibold text-white" }, Number(item.metrics?.comments || 0).toLocaleString()))) )))))); } -const __vite_glob_0_130 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_142 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioGrowth }, Symbol.toStringTag, { value: "Module" })); @@ -101184,7 +104412,7 @@ function StudioInbox() { }; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description, actions: /* @__PURE__ */ React.createElement("button", { type: "button", onClick: markAllRead, disabled: marking, className: "inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-slate-100 disabled:opacity-50" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-check-double" }), marking ? "Updating..." : "Mark all read") }, /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("section", { className: "grid gap-4 md:grid-cols-2 xl:grid-cols-4" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Unread"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-3xl font-semibold text-white" }, Number(summary.unread_count || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "High priority"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-3xl font-semibold text-white" }, Number(summary.high_priority_count || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Comments"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-3xl font-semibold text-white" }, Number(summary.comment_count || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Followers"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-3xl font-semibold text-white" }, Number(summary.follower_count || 0).toLocaleString()))), /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]" }, /* @__PURE__ */ React.createElement("aside", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Filters"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, /* @__PURE__ */ React.createElement("label", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Search"), /* @__PURE__ */ React.createElement("input", { value: filters.q || "", onChange: (event) => updateFilters({ q: event.target.value }), className: "w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white", placeholder: "Actor, title, or module" })), /* @__PURE__ */ React.createElement("div", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Type"), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.type || "all", onChange: (val) => updateFilters({ type: val }), options: inbox.type_options || [], searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Module"), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.module || "all", onChange: (val) => updateFilters({ module: val }), options: inbox.module_options || [], searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Read state"), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.read_state || "all", onChange: (val) => updateFilters({ read_state: val }), options: inbox.read_state_options || [], searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Priority"), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.priority || "all", onChange: (val) => updateFilters({ priority: val }), options: inbox.priority_options || [], searchable: false })))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Attention now"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, (inbox.panels?.attention_now || []).map((item) => /* @__PURE__ */ React.createElement("a", { key: item.id, href: item.url, className: "block rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs text-slate-500" }, item.module_label)))))), /* @__PURE__ */ React.createElement("section", { className: "space-y-4" }, items.length > 0 ? items.map((item) => /* @__PURE__ */ React.createElement("article", { key: item.id, className: `rounded-[28px] border p-5 ${item.is_new ? "border-sky-300/20 bg-sky-300/10" : "border-white/10 bg-white/[0.03]"}` }, /* @__PURE__ */ React.createElement("div", { className: "flex gap-4" }, item.actor?.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: item.actor.avatar_url, alt: item.actor.name || "Actor", className: "h-12 w-12 rounded-2xl object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-12 w-12 items-center justify-center rounded-2xl bg-black/20 text-slate-400" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-bell" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, /* @__PURE__ */ React.createElement("span", null, item.module_label), /* @__PURE__ */ React.createElement("span", { className: `inline-flex items-center rounded-full border px-2 py-1 ${priorityClasses[item.priority] || priorityClasses.low}` }, item.priority), item.is_new && /* @__PURE__ */ React.createElement("span", { className: "rounded-full bg-sky-300/20 px-2 py-1 text-sky-100" }, "Unread")), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-lg font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-slate-400" }, item.body), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap items-center gap-3 text-sm text-slate-400" }, /* @__PURE__ */ React.createElement("span", null, formatDate$2(item.created_at)), item.actor?.name && /* @__PURE__ */ React.createElement("span", null, item.actor.name), /* @__PURE__ */ React.createElement("a", { href: item.url, className: "inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1.5 text-slate-200" }, "Open")))))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-dashed border-white/15 px-6 py-16 text-center text-slate-400" }, "No inbox items match this filter."), /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("button", { type: "button", disabled: (meta.current_page || 1) <= 1, onClick: () => updateFilters({ page: Math.max(1, (meta.current_page || 1) - 1) }), className: "rounded-full border border-white/10 px-4 py-2 disabled:opacity-40" }, "Previous"), /* @__PURE__ */ React.createElement("span", { className: "text-xs uppercase tracking-[0.18em] text-slate-500" }, "Page ", meta.current_page || 1, " of ", meta.last_page || 1), /* @__PURE__ */ React.createElement("button", { type: "button", disabled: (meta.current_page || 1) >= (meta.last_page || 1), onClick: () => updateFilters({ page: (meta.current_page || 1) + 1 }), className: "rounded-full border border-white/10 px-4 py-2 disabled:opacity-40" }, "Next")))))); } -const __vite_glob_0_131 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_143 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioInbox }, Symbol.toStringTag, { value: "Module" })); @@ -101543,7 +104771,7 @@ function RelationCard({ relation, index: index2, onChange, onRemove, onSearch, r function stripHtml$1(value) { return String(value || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); } -const NEWS_NEW_TAG_LIMIT = 12; +const NEWS_NEW_TAG_LIMIT = 30; function slugifyNewsTitle(value) { return String(value || "").normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 180); } @@ -101574,7 +104802,6 @@ function buildSubmitPayload(data) { meta_title: String(data.meta_title || ""), meta_description: String(data.meta_description || ""), meta_keywords: String(data.meta_keywords || ""), - canonical_url: String(data.canonical_url || "").trim(), og_title: String(data.og_title || ""), og_description: String(data.og_description || ""), og_image: String(data.og_image || "").trim(), @@ -101618,7 +104845,6 @@ function buildInitialFormData(article, defaultAuthor, typeOptions, oldInput = {} meta_title: String(getDraftValue(oldInput, "meta_title", article.meta_title || "")), meta_description: String(getDraftValue(oldInput, "meta_description", article.meta_description || "")), meta_keywords: String(getDraftValue(oldInput, "meta_keywords", article.meta_keywords || "")), - canonical_url: String(getDraftValue(oldInput, "canonical_url", article.canonical_url || "")), og_title: String(getDraftValue(oldInput, "og_title", article.og_title || "")), og_description: String(getDraftValue(oldInput, "og_description", article.og_description || "")), og_image: String(getDraftValue(oldInput, "og_image", article.og_image || "")), @@ -101681,6 +104907,20 @@ function normalizeImportedTagList(value) { return normalizeNewTagName(item); }).filter(Boolean); } +function normalizeImportedDateTime(value) { + const raw = String(value || "").trim(); + if (!raw) return ""; + const dateTimeMatch = raw.match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2})(?::\d{2})?)?$/); + if (dateTimeMatch) { + return dateTimeMatch[2] ? `${dateTimeMatch[1]}T${dateTimeMatch[2]}` : dateTimeMatch[1]; + } + const parsed = new Date(raw); + if (Number.isNaN(parsed.getTime())) { + return raw; + } + const pad2 = (input) => String(input).padStart(2, "0"); + return `${parsed.getFullYear()}-${pad2(parsed.getMonth() + 1)}-${pad2(parsed.getDate())}T${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`; +} function parseStructuredNewsImport(rawValue, context) { const parsed = JSON.parse(String(rawValue || "").trim()); const categoryOptions = Array.isArray(context.categoryOptions) ? context.categoryOptions : []; @@ -101704,11 +104944,13 @@ function parseStructuredNewsImport(rawValue, context) { applyString("excerpt"); applyString("content"); applyString("cover_image"); - applyString("published_at"); + if (parsed.published_at != null) { + next.published_at = normalizeImportedDateTime(parsed.published_at); + applied.push("published_at"); + } applyString("meta_title"); applyString("meta_description"); applyString("meta_keywords"); - applyString("canonical_url"); applyString("og_title"); applyString("og_description"); applyString("og_image"); @@ -101773,15 +105015,156 @@ function parseStructuredNewsImport(rawValue, context) { authorQuery: parsed.author_query != null ? String(parsed.author_query) : parsed.author_name != null ? String(parsed.author_name) : null }; } -function JsonImportDialog({ open, value, error, onChange, onClose, onApply, newTagLimit = NEWS_NEW_TAG_LIMIT }) { +let newsMarkdownTurndown = null; +let newsMarkdownTurndownPromise = null; +async function loadNewsMarkdownTurndown() { + if (newsMarkdownTurndown) { + return newsMarkdownTurndown; + } + if (typeof window === "undefined") { + return null; + } + if (!newsMarkdownTurndownPromise) { + newsMarkdownTurndownPromise = import("./assets/turndown.es-8lfE8z0s.js").then(({ default: TurndownService }) => new TurndownService({ + headingStyle: "atx", + codeBlockStyle: "fenced", + bulletListMarker: "-", + emDelimiter: "*" + })).then((service) => { + newsMarkdownTurndown = service; + return service; + }).catch(() => null); + } + return newsMarkdownTurndownPromise; +} +function findNewsOptionById(options, value) { + const normalized = String(value || "").trim(); + if (!normalized) return null; + return (Array.isArray(options) ? options : []).find((option) => String(option.id ?? option.value ?? "").trim() === normalized) || null; +} +function findNewsTagsByIds(options, ids) { + const idSet = new Set((Array.isArray(ids) ? ids : []).map((id) => Number(id))); + return (Array.isArray(options) ? options : []).filter((option) => idSet.has(Number(option.id))).map((option) => ({ + id: Number(option.id), + name: String(option.name || option.label || ""), + slug: String(option.slug || "") + })); +} +function buildStructuredPlainTextExport(data) { + const lines = []; + if (data.title) lines.push(`Title: ${data.title}`); + if (data.excerpt) lines.push(`Excerpt: ${data.excerpt}`); + if (data.date) lines.push(`Date: ${data.date}`); + if (data.category) lines.push(`Category: ${data.category}`); + if (data.body) { + lines.push(""); + lines.push("Body:"); + lines.push(data.body); + } + return lines.join("\n").trim(); +} +function convertNewsHtmlToMarkdown(value) { + const html2 = String(value || "").trim(); + if (!html2) return ""; + if (!newsMarkdownTurndown) { + return stripHtml$1(html2); + } + return newsMarkdownTurndown.turndown(html2).trim(); +} +function buildNewsMarkdownExport(data) { + const lines = []; + if (data.title) { + lines.push(`# ${data.title}`); + } + if (data.excerpt) { + lines.push(data.excerpt); + } + if (data.date) { + lines.push(`- Date: ${data.date}`); + } + if (data.category) { + lines.push(`- Category: ${data.category}`); + } + const bodyMarkdown = convertNewsHtmlToMarkdown(data.body_html); + if (bodyMarkdown) { + lines.push(bodyMarkdown); + } + return lines.join("\n\n").trim(); +} +function buildNewsExportPayloads(data, context = {}) { + const normalized = buildSubmitPayload(data || {}); + const category = findNewsOptionById(context.categoryOptions, normalized.category_id); + const existingTags = findNewsTagsByIds(context.tagOptions, normalized.tag_ids); + const author = context.author || null; + const full = { + title: normalized.title, + slug: normalized.slug, + excerpt: normalized.excerpt, + content: normalized.content, + cover_image: normalized.cover_image, + type: normalized.type, + category_id: normalized.category_id, + category: category?.name ?? category?.label ?? "", + category_slug: category?.slug ?? "", + author_id: normalized.author_id, + author_name: author?.title ?? author?.name ?? "", + editorial_status: normalized.editorial_status, + published_at: normalized.published_at, + is_featured: normalized.is_featured, + is_pinned: normalized.is_pinned, + comments_enabled: normalized.comments_enabled, + tags: [ + ...existingTags, + ...normalized.new_tag_names.map((name2) => ({ name: name2, slug: "" })) + ], + tag_names: [ + ...existingTags.map((tag) => tag.name), + ...normalized.new_tag_names + ], + tag_ids: normalized.tag_ids, + new_tag_names: normalized.new_tag_names, + meta_title: normalized.meta_title, + meta_description: normalized.meta_description, + meta_keywords: normalized.meta_keywords, + og_title: normalized.og_title, + og_description: normalized.og_description, + og_image: normalized.og_image, + relations: normalized.relations + }; + const structured = { + title: normalized.title, + excerpt: normalized.excerpt, + date: normalized.published_at, + body: stripHtml$1(normalized.content), + category: category?.name ?? category?.label ?? "" + }; + const markdown = { + title: normalized.title, + excerpt: normalized.excerpt, + date: normalized.published_at, + category: category?.name ?? category?.label ?? "", + body_html: normalized.content + }; + return { + full: JSON.stringify(full, null, 2), + structured: JSON.stringify(structured, null, 2), + structuredPlain: buildStructuredPlainTextExport(structured), + markdown: buildNewsMarkdownExport(markdown), + markdownInput: markdown + }; +} +function JsonImportDialog({ open, value, error, onChange, onClose, onApply, exportPayloads, newTagLimit = NEWS_NEW_TAG_LIMIT }) { const backdropRef = reactExports.useRef(null); const [activeImportTab, setActiveImportTab] = reactExports.useState("input"); const [copyFeedback, setCopyFeedback] = reactExports.useState(""); + const [exportMode, setExportMode] = reactExports.useState("full"); + const [markdownExportText, setMarkdownExportText] = reactExports.useState(String(exportPayloads?.markdown || "")); const importTabs = [ { id: "input", label: "Input", description: "Paste JSON and apply it to the editor." }, { id: "structure", label: "Structure example", description: "A working example of the expected payload." }, { id: "docs", label: "Documentation", description: "Field notes and mapping rules." }, - { id: "prompts", label: "AI prompts", description: "Prompt examples for generating structured news." } + { id: "prompts", label: "AI prompts", description: "Prompt examples for generating structured news." }, + { id: "export", label: "Export", description: "Copy the current article out as JSON, text, or Markdown." } ]; const structureExample = { title: "Sample News Title", @@ -101815,7 +105198,6 @@ function JsonImportDialog({ open, value, error, onChange, onClose, onApply, newT meta_title: "Sample News Title - Skinbase Example", meta_description: "This is a sample news meta description for the structured import example.", meta_keywords: "sample news, structured import, editorial example", - canonical_url: "https://skinbase.org/news/sample-news-title", og_title: "Sample News Title", og_description: "This is a sample news OG description for the structured import example.", og_image: "sample-news-cover.webp" @@ -101841,7 +105223,6 @@ Recommended fields: - is_featured: boolean - is_pinned: boolean - meta_title, meta_description, meta_keywords -- canonical_url - og_title, og_description, og_image - tags: array of strings or objects with name/title/label/slug - tag_names: array of strings @@ -101866,7 +105247,7 @@ Transform the following article into a news payload for the editor. - Write content as HTML paragraphs. - Include 8 to 14 highly relevant tags. - Include category_id when possible, otherwise use category_slug or category to help matching. -- Fill meta_title, meta_description, canonical_url, og_title, og_description, and og_image when available. +- Fill meta_title, meta_description, og_title, og_description, and og_image when available. - Make comments_enabled true unless the source clearly says otherwise. Input article text: @@ -101907,6 +105288,7 @@ Source article: function tabButtonClass(active) { return `flex-1 rounded-2xl border px-4 py-3 text-left transition ${active ? "border-sky-300/25 bg-sky-400/10 text-white" : "border-white/10 bg-white/[0.03] text-slate-400 hover:border-white/20 hover:bg-white/[0.05] hover:text-slate-200"}`; } + const activeExportText = exportMode === "structured" ? String(exportPayloads?.structured || "") : exportMode === "markdown" ? markdownExportText : String(exportPayloads?.full || ""); const copyText = async (text2, label) => { try { await navigator.clipboard.writeText(String(text2)); @@ -101927,6 +105309,24 @@ Source article: window.addEventListener("keydown", handleKeyDown2); return () => window.removeEventListener("keydown", handleKeyDown2); }, [onClose, open]); + reactExports.useEffect(() => { + setMarkdownExportText(String(exportPayloads?.markdown || "")); + }, [exportPayloads]); + reactExports.useEffect(() => { + if (!open || activeImportTab !== "export" || exportMode !== "markdown") { + return void 0; + } + let cancelled = false; + loadNewsMarkdownTurndown().then(() => { + if (cancelled) { + return; + } + setMarkdownExportText(buildNewsMarkdownExport(exportPayloads?.markdownInput || {})); + }); + return () => { + cancelled = true; + }; + }, [activeImportTab, exportMode, exportPayloads, open]); if (!open) return null; return reactDomExports.createPortal( /* @__PURE__ */ React.createElement( @@ -101949,8 +105349,8 @@ Source article: "aria-labelledby": "news-json-import-title", className: "flex h-[min(90vh,780px)] w-full max-w-5xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]" }, - /* @__PURE__ */ React.createElement("div", { className: "border-b border-white/[0.06] bg-white/[0.02] px-6 py-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35" }, "Structured import"), /* @__PURE__ */ React.createElement("h3", { id: "news-json-import-title", className: "mt-2 text-lg font-semibold text-white" }, "Paste article JSON"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-white/65" }, "Use this for migrations, AI-assisted drafting, or bulk handoff from another editorial system. Matching fields are applied directly to the editor.")), - /* @__PURE__ */ React.createElement("div", { className: "border-b border-white/[0.06] px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 md:grid-cols-4" }, importTabs.map((tab2) => /* @__PURE__ */ React.createElement( + /* @__PURE__ */ React.createElement("div", { className: "border-b border-white/[0.06] bg-white/[0.02] px-6 py-5" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35" }, "Structured import"), /* @__PURE__ */ React.createElement("h3", { id: "news-json-import-title", className: "mt-2 text-lg font-semibold text-white" }, "Import or export article JSON"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-white/65" }, "Use this for migrations, AI-assisted drafting, bulk handoff from another editorial system, or copying the current article into reusable JSON.")), + /* @__PURE__ */ React.createElement("div", { className: "border-b border-white/[0.06] px-4 py-4" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 md:grid-cols-5" }, importTabs.map((tab2) => /* @__PURE__ */ React.createElement( "button", { key: tab2.id, @@ -101970,7 +105370,7 @@ Source article: placeholder: '{\n "title": "My news title",\n "slug": "my-news-title",\n "excerpt": "Short summary",\n "tags": ["release", "community"]\n}', className: "nova-scrollbar w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-white outline-none placeholder:text-white/30" } - ), error ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100" }, error) : null), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Recognized keys"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "`title`, `slug`, `excerpt`, `content`, `cover_image`"), /* @__PURE__ */ React.createElement("p", null, "`type`, `category_id`, `category`, `category_slug`"), /* @__PURE__ */ React.createElement("p", null, "`editorial_status`, `published_at`, `author_id`, `author_name`"), /* @__PURE__ */ React.createElement("p", null, "`is_featured`, `is_pinned`, `comments_enabled`"), /* @__PURE__ */ React.createElement("p", null, "`tags`, `tag_names`, `tag_ids`, `relations`"), /* @__PURE__ */ React.createElement("p", null, "`new_tag_names` is capped at ", newTagLimit, " items per article."), /* @__PURE__ */ React.createElement("p", null, "`meta_title`, `meta_description`, `meta_keywords`, `canonical_url`, `og_title`, `og_description`, `og_image`")))) : null, activeImportTab === "structure" ? /* @__PURE__ */ React.createElement("div", { className: "grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "mb-3 flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Structure example"), /* @__PURE__ */ React.createElement( + ), error ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100" }, error) : null), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Recognized keys"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "`title`, `slug`, `excerpt`, `content`, `cover_image`"), /* @__PURE__ */ React.createElement("p", null, "`type`, `category_id`, `category`, `category_slug`"), /* @__PURE__ */ React.createElement("p", null, "`editorial_status`, `published_at`, `author_id`, `author_name`"), /* @__PURE__ */ React.createElement("p", null, "`is_featured`, `is_pinned`, `comments_enabled`"), /* @__PURE__ */ React.createElement("p", null, "`tags`, `tag_names`, `tag_ids`, `relations`"), /* @__PURE__ */ React.createElement("p", null, "`new_tag_names` is capped at ", newTagLimit, " items per article."), /* @__PURE__ */ React.createElement("p", null, "`meta_title`, `meta_description`, `meta_keywords`, `og_title`, `og_description`, `og_image`")))) : null, activeImportTab === "structure" ? /* @__PURE__ */ React.createElement("div", { className: "grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "mb-3 flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Structure example"), /* @__PURE__ */ React.createElement( "button", { type: "button", @@ -101986,7 +105386,42 @@ Source article: className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]" }, "Copy prompt" - )), /* @__PURE__ */ React.createElement("pre", { className: "nova-scrollbar mt-3 max-h-56 overflow-auto whitespace-pre-wrap rounded-[18px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-6 text-slate-200" }, example.prompt)))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Prompt tips"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "Tell the model to return JSON only, with no explanation text."), /* @__PURE__ */ React.createElement("p", null, "Ask for `tags` as an array of objects when you want the most compatible import shape."), /* @__PURE__ */ React.createElement("p", null, "Include `source_urls` or reference links in the source instruction if you want them copied into the story notes.")))) : null), + )), /* @__PURE__ */ React.createElement("pre", { className: "nova-scrollbar mt-3 max-h-56 overflow-auto whitespace-pre-wrap rounded-[18px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-6 text-slate-200" }, example.prompt)))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Prompt tips"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, "Tell the model to return JSON only, with no explanation text."), /* @__PURE__ */ React.createElement("p", null, "Ask for `tags` as an array of objects when you want the most compatible import shape."), /* @__PURE__ */ React.createElement("p", null, "Include `source_urls` or reference links in the source instruction if you want them copied into the story notes.")))) : null, activeImportTab === "export" ? /* @__PURE__ */ React.createElement("div", { className: "grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + onClick: () => setExportMode("full"), + className: tabButtonClass(exportMode === "full") + }, + /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold" }, "Full news JSON"), + /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs leading-5 text-current/70" }, "Exports the current article with metadata, tags, and relations.") + ), /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + onClick: () => setExportMode("structured"), + className: tabButtonClass(exportMode === "structured") + }, + /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold" }, "Structured JSON"), + /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs leading-5 text-current/70" }, "Exports only title, excerpt, date, body, and category.") + ), /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + onClick: () => setExportMode("markdown"), + className: tabButtonClass(exportMode === "markdown") + }, + /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold" }, "Markdown"), + /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs leading-5 text-current/70" }, "Exports the current article as Markdown with heading, summary, and body.") + )), /* @__PURE__ */ React.createElement( + "textarea", + { + readOnly: true, + value: activeExportText, + rows: 18, + className: "nova-scrollbar w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-white outline-none" + } + )), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Export options"), /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-3 leading-6 text-slate-400" }, /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "Full news JSON"), " includes the current editable article state: slug, status, tags, metadata, and relations."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "Structured JSON"), " keeps the reduced handoff shape: title, excerpt, date, body, and category."), /* @__PURE__ */ React.createElement("p", null, /* @__PURE__ */ React.createElement("strong", { className: "text-slate-200" }, "Markdown"), " converts the current article body into Markdown and includes the title plus summary fields for external reuse."), /* @__PURE__ */ React.createElement("p", null, "The export uses the live editor state, so unsaved changes are included immediately.")))) : null), copyFeedback ? /* @__PURE__ */ React.createElement("div", { className: "px-6 pb-2 text-right text-xs font-medium text-sky-200/80" }, copyFeedback) : null, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4" }, /* @__PURE__ */ React.createElement( "button", @@ -101996,7 +105431,23 @@ Source article: className: "inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white" }, "Cancel" - ), /* @__PURE__ */ React.createElement( + ), activeImportTab === "export" ? /* @__PURE__ */ React.createElement(React.Fragment, null, exportMode === "structured" ? /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + onClick: () => copyText(String(exportPayloads?.structuredPlain || ""), "Structured plain text export"), + className: "inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white" + }, + "Copy plain text" + ) : null, /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + onClick: () => copyText(activeExportText, exportMode === "structured" ? "Structured export" : exportMode === "markdown" ? "Markdown export" : "Full news export"), + className: "inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110" + }, + "Copy export" + )) : /* @__PURE__ */ React.createElement( "button", { type: "button", @@ -102113,7 +105564,7 @@ function StudioNewsEditor() { const tabErrorCounts = reactExports.useMemo(() => ({ content: ["title", "slug", "excerpt", "content", "cover_image"].filter((key) => Boolean(form.errors[key])).length, publishing: ["type", "category_id", "author_id", "editorial_status", "published_at", "comments_enabled"].filter((key) => Boolean(form.errors[key])).length, - discoverability: ["tag_ids", "new_tag_names", "meta_title", "meta_description", "meta_keywords", "canonical_url", "og_title", "og_description", "og_image"].filter((key) => Boolean(form.errors[key])).length, + discoverability: ["tag_ids", "new_tag_names", "meta_title", "meta_description", "meta_keywords", "og_title", "og_description", "og_image"].filter((key) => Boolean(form.errors[key])).length, connections: ["relations"].filter((key) => Boolean(form.errors[key])).length }), [form.errors]); const overviewItems = reactExports.useMemo(() => [ @@ -102125,6 +105576,11 @@ function StudioNewsEditor() { { label: "Author", done: Boolean(form.data.author_id) } ], [bodyWordCount, form.data.author_id, form.data.category_id, form.data.cover_image, form.data.excerpt, form.data.title]); const completedCount = overviewItems.filter((item) => item.done).length; + const jsonExportPayloads = reactExports.useMemo(() => buildNewsExportPayloads(form.data, { + categoryOptions: props.categoryOptions, + tagOptions: props.tagOptions, + author: selectedAuthor + }), [form.data, props.categoryOptions, props.tagOptions, selectedAuthor]); reactExports.useEffect(() => { const firstErrorTab = NEWS_EDITOR_TABS.find((tab2) => tabErrorCounts[tab2.id] > 0); if (firstErrorTab) { @@ -102372,7 +105828,7 @@ function StudioNewsEditor() { maxLength: 255, className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" } - ), /* @__PURE__ */ React.createElement("span", { className: "text-xs leading-5 text-slate-500" }, "Maximum 255 characters. The field now stops at the limit so it fails less often on save."), /* @__PURE__ */ React.createElement(FieldError, { message: form.errors.meta_keywords })), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Canonical URL"), /* @__PURE__ */ React.createElement("input", { value: form.data.canonical_url, onChange: (event) => form.setData("canonical_url", event.target.value), placeholder: "https://...", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "OG title"), /* @__PURE__ */ React.createElement("input", { value: form.data.og_title, onChange: (event) => form.setData("og_title", event.target.value), className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "OG image"), /* @__PURE__ */ React.createElement("input", { value: form.data.og_image, onChange: (event) => form.setData("og_image", event.target.value), className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }))), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "OG description"), /* @__PURE__ */ React.createElement("textarea", { value: form.data.og_description, onChange: (event) => form.setData("og_description", event.target.value), rows: 3, className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }))))) : null, activeTab === "connections" ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(SectionCard, { eyebrow: "Context links", title: "Related entities", description: "Attach groups, artworks, collections, releases, projects, challenges, events, and profiles so the article becomes part of the rest of Nova instead of a dead-end page.", actions: /* @__PURE__ */ React.createElement("button", { type: "button", onClick: addRelation, className: "rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100" }, "Add relation") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4" }, form.data.relations.length > 0 ? form.data.relations.map((relation, index2) => /* @__PURE__ */ React.createElement( + ), /* @__PURE__ */ React.createElement("span", { className: "text-xs leading-5 text-slate-500" }, "Maximum 255 characters. The field now stops at the limit so it fails less often on save."), /* @__PURE__ */ React.createElement(FieldError, { message: form.errors.meta_keywords })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "OG title"), /* @__PURE__ */ React.createElement("input", { value: form.data.og_title, onChange: (event) => form.setData("og_title", event.target.value), className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "OG image"), /* @__PURE__ */ React.createElement("input", { value: form.data.og_image, onChange: (event) => form.setData("og_image", event.target.value), className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }))), /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "OG description"), /* @__PURE__ */ React.createElement("textarea", { value: form.data.og_description, onChange: (event) => form.setData("og_description", event.target.value), rows: 3, className: "rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }))))) : null, activeTab === "connections" ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(SectionCard, { eyebrow: "Context links", title: "Related entities", description: "Attach groups, artworks, collections, releases, projects, challenges, events, and profiles so the article becomes part of the rest of Nova instead of a dead-end page.", actions: /* @__PURE__ */ React.createElement("button", { type: "button", onClick: addRelation, className: "rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100" }, "Add relation") }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4" }, form.data.relations.length > 0 ? form.data.relations.map((relation, index2) => /* @__PURE__ */ React.createElement( RelationCard, { key: `${relation.entity_type}-${index2}`, @@ -102390,6 +105846,7 @@ function StudioNewsEditor() { open: jsonImportOpen, value: jsonImportValue, error: jsonImportError, + exportPayloads: jsonExportPayloads, newTagLimit: props.newsTagLimit || NEWS_NEW_TAG_LIMIT, onChange: (nextValue) => { setJsonImportValue(nextValue); @@ -102405,7 +105862,7 @@ function StudioNewsEditor() { } )); } -const __vite_glob_0_132 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_144 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioNewsEditor }, Symbol.toStringTag, { value: "Module" })); @@ -102433,11 +105890,32 @@ function statusTone$1(status2) { return "border-white/10 bg-white/[0.05] text-slate-300"; } } +function buildPaginationPages(current, last) { + if (last <= 1) return [1]; + if (last <= 7) { + return Array.from({ length: last }, (_2, index2) => index2 + 1); + } + const pages2 = /* @__PURE__ */ new Set([1, 2, current - 1, current, current + 1, last - 1, last]); + const sorted = [...pages2].filter((page) => page >= 1 && page <= last).sort((left, right) => left - right); + const result = []; + for (let index2 = 0; index2 < sorted.length; index2 += 1) { + if (index2 > 0 && sorted[index2] - sorted[index2 - 1] > 1) { + result.push("ellipsis"); + } + result.push(sorted[index2]); + } + return result; +} function StudioNewsIndex() { const { props } = X$1(); const items = Array.isArray(props.listing?.items) ? props.listing.items : []; const filters = props.listing?.filters || {}; const meta = props.listing?.meta || {}; + const currentPage = Number(meta.current_page || 1); + const lastPage = Number(meta.last_page || 1); + const from = Number(meta.from || 0); + const to = Number(meta.to || 0); + const paginationPages = buildPaginationPages(currentPage, lastPage); const deleteItem = (item) => { if (!item?.delete_url) return; if (!window.confirm(`Move "${item.title}" to trash?`)) return; @@ -102445,11 +105923,12 @@ function StudioNewsIndex() { preserveScroll: true }); }; - const updateFilter = (next) => { + const updateFilter = (next, resetPage = true) => { + const hasExplicitPage = Object.prototype.hasOwnProperty.call(next, "page"); At.get("/studio/news", { ...filters, ...next, - page: 1 + page: hasExplicitPage ? next.page : resetPage ? 1 : currentPage }, { preserveState: true, preserveScroll: true @@ -102467,7 +105946,9 @@ function StudioNewsIndex() { q: event.currentTarget.value || "", status: filters.status || "", type: filters.type || "", - category_id: filters.category_id || "" + category_id: filters.category_id || "", + order: filters.order || "", + direction: filters.direction || "" }); } } @@ -102476,7 +105957,7 @@ function StudioNewsIndex() { NovaSelect, { value: filters.status || "", - onChange: (value) => updateFilter({ status: value, q: filters.q || "", type: filters.type || "", category_id: filters.category_id || "" }), + onChange: (value) => updateFilter({ status: value, q: filters.q || "", type: filters.type || "", category_id: filters.category_id || "", order: filters.order || "", direction: filters.direction || "" }), placeholder: "All statuses", options: (Array.isArray(props.statusOptions) ? props.statusOptions : []).map((option) => ({ value: option.value, label: option.label })), searchable: false @@ -102485,7 +105966,7 @@ function StudioNewsIndex() { NovaSelect, { value: filters.type || "", - onChange: (value) => updateFilter({ type: value, q: filters.q || "", status: filters.status || "", category_id: filters.category_id || "" }), + onChange: (value) => updateFilter({ type: value, q: filters.q || "", status: filters.status || "", category_id: filters.category_id || "", order: filters.order || "", direction: filters.direction || "" }), placeholder: "All types", options: (Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => ({ value: option.value, label: option.label })), searchable: false @@ -102494,14 +105975,80 @@ function StudioNewsIndex() { NovaSelect, { value: filters.category_id || "", - onChange: (value) => updateFilter({ category_id: value, q: filters.q || "", status: filters.status || "", type: filters.type || "" }), + onChange: (value) => updateFilter({ category_id: value, q: filters.q || "", status: filters.status || "", type: filters.type || "", order: filters.order || "", direction: filters.direction || "" }), placeholder: "All categories", options: (Array.isArray(props.categoryOptions) ? props.categoryOptions : []).map((option) => ({ value: String(option.id), label: option.name })), searchable: false } - )), /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-400 lg:text-right" }, Number(meta.total || 0).toLocaleString(), " articles"))), /* @__PURE__ */ React.createElement("section", { className: "mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3" }, items.length > 0 ? items.map((item) => /* @__PURE__ */ React.createElement("article", { key: item.id, className: "overflow-hidden rounded-[24px] border border-white/10 bg-black/20 shadow-[0_18px_40px_rgba(2,6,23,0.18)]" }, /* @__PURE__ */ React.createElement("div", { className: "aspect-[16/9] bg-slate-950/60" }, item.cover_url ? /* @__PURE__ */ React.createElement("img", { src: item.cover_url, alt: item.title, className: "h-full w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-full items-center justify-center text-slate-500" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-newspaper text-3xl" }))), /* @__PURE__ */ React.createElement("div", { className: "p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-white/70" }, item.type_label), /* @__PURE__ */ React.createElement("span", { className: `rounded-full border px-2.5 py-1 ${statusTone$1(item.editorial_status)}` }, item.editorial_status.replaceAll("_", " ")), item.is_pinned ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/20 bg-amber-400/10 px-2.5 py-1 text-amber-100" }, "Pinned") : null, item.is_featured ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-emerald-300/20 bg-emerald-400/10 px-2.5 py-1 text-emerald-100" }, "Featured") : null), /* @__PURE__ */ React.createElement("h3", { className: "mt-3 text-xl font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex flex-wrap gap-3 text-sm text-slate-400" }, item.category_name ? /* @__PURE__ */ React.createElement("span", null, item.category_name) : null, /* @__PURE__ */ React.createElement("span", null, item.author_name), /* @__PURE__ */ React.createElement("span", null, formatDate$1(item.published_at))), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("a", { href: item.edit_url, className: "rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100" }, "Edit"), /* @__PURE__ */ React.createElement("a", { href: item.editorial_status === "published" ? item.public_url : item.preview_url, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, item.editorial_status === "published" ? "View" : "Preview"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => deleteItem(item), className: "rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100" }, "Trash"))))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400" }, "No News articles match the current filters."))); + )), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Order"), /* @__PURE__ */ React.createElement( + NovaSelect, + { + value: filters.order || "", + onChange: (value) => updateFilter({ order: value, q: filters.q || "", status: filters.status || "", type: filters.type || "", category_id: filters.category_id || "", direction: filters.direction || "" }), + placeholder: "Order by", + options: [ + { value: "date", label: "Date" }, + { value: "title", label: "Title" }, + { value: "views", label: "Views" } + ], + searchable: false + } + )), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Direction"), /* @__PURE__ */ React.createElement( + NovaSelect, + { + value: filters.direction || "", + onChange: (value) => updateFilter({ direction: value, q: filters.q || "", status: filters.status || "", type: filters.type || "", category_id: filters.category_id || "", order: filters.order || "" }), + placeholder: "Asc / Desc", + options: [ + { value: "desc", label: "Desc" }, + { value: "asc", label: "Asc" } + ], + searchable: false + } + )), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Per page"), /* @__PURE__ */ React.createElement( + NovaSelect, + { + value: String(filters.per_page || 15), + onChange: (value) => updateFilter({ per_page: value }), + placeholder: "Per page", + options: [ + { value: "15", label: "15 articles" }, + { value: "30", label: "30 articles" }, + { value: "50", label: "50 articles" } + ], + searchable: false + } + )), /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-400 lg:text-right" }, Number(meta.total || 0).toLocaleString(), " articles"))), /* @__PURE__ */ React.createElement("section", { className: "mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3" }, items.length > 0 ? items.map((item) => /* @__PURE__ */ React.createElement("article", { key: item.id, className: "overflow-hidden rounded-[24px] border border-white/10 bg-black/20 shadow-[0_18px_40px_rgba(2,6,23,0.18)]" }, /* @__PURE__ */ React.createElement("div", { className: "aspect-[16/9] bg-slate-950/60" }, item.cover_url ? /* @__PURE__ */ React.createElement("img", { src: item.cover_url, alt: item.title, className: "h-full w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-full items-center justify-center text-slate-500" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-newspaper text-3xl" }))), /* @__PURE__ */ React.createElement("div", { className: "p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400" }, /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-white/70" }, item.type_label), /* @__PURE__ */ React.createElement("span", { className: `rounded-full border px-2.5 py-1 ${statusTone$1(item.editorial_status)}` }, item.editorial_status.replaceAll("_", " ")), item.is_pinned ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-amber-300/20 bg-amber-400/10 px-2.5 py-1 text-amber-100" }, "Pinned") : null, item.is_featured ? /* @__PURE__ */ React.createElement("span", { className: "rounded-full border border-emerald-300/20 bg-emerald-400/10 px-2.5 py-1 text-emerald-100" }, "Featured") : null), /* @__PURE__ */ React.createElement("h3", { className: "mt-3 text-xl font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex flex-wrap gap-3 text-sm text-slate-400" }, item.category_name ? /* @__PURE__ */ React.createElement("span", null, item.category_name) : null, /* @__PURE__ */ React.createElement("span", null, item.author_name), /* @__PURE__ */ React.createElement("span", null, formatDate$1(item.published_at))), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("a", { href: item.edit_url, className: "rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100" }, "Edit"), /* @__PURE__ */ React.createElement("a", { href: item.editorial_status === "published" ? item.public_url : item.preview_url, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white" }, item.editorial_status === "published" ? "View" : "Preview"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => deleteItem(item), className: "rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100" }, "Trash"))))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400" }, "No News articles match the current filters.")), lastPage > 1 ? /* @__PURE__ */ React.createElement("div", { className: "mt-6 flex flex-col gap-3 rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-300 sm:flex-row sm:items-center sm:justify-between" }, /* @__PURE__ */ React.createElement("div", { className: "text-slate-400" }, "Showing ", from.toLocaleString(), "-", to.toLocaleString(), " of ", Number(meta.total || 0).toLocaleString(), " articles"), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + disabled: currentPage <= 1, + onClick: () => updateFilter({ page: Math.max(1, currentPage - 1) }, false), + className: "rounded-full border border-white/10 px-4 py-2 font-semibold text-white transition hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-40" + }, + "Previous" + ), paginationPages.map((page, index2) => page === "ellipsis" ? /* @__PURE__ */ React.createElement("span", { key: `ellipsis-${index2}`, className: "px-2 text-slate-500" }, "...") : /* @__PURE__ */ React.createElement( + "button", + { + key: page, + type: "button", + onClick: () => updateFilter({ page }, false), + "aria-current": page === currentPage ? "page" : void 0, + className: `min-w-10 rounded-full border px-3 py-2 text-sm font-semibold transition ${page === currentPage ? "border-sky-300/20 bg-sky-400/10 text-sky-100" : "border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.06]"}` + }, + page + )), /* @__PURE__ */ React.createElement("span", { className: "ml-1 text-xs uppercase tracking-[0.18em] text-slate-500" }, "Page ", currentPage, " of ", lastPage), /* @__PURE__ */ React.createElement( + "button", + { + type: "button", + disabled: currentPage >= lastPage, + onClick: () => updateFilter({ page: currentPage + 1 }, false), + className: "rounded-full border border-white/10 px-4 py-2 font-semibold text-white transition hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-40" + }, + "Next" + ))) : null); } -const __vite_glob_0_133 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_145 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioNewsIndex }, Symbol.toStringTag, { value: "Module" })); @@ -102534,7 +106081,7 @@ function StudioNewsTaxonomies() { tagForm.post(props.storeTagUrl); }, className: "mt-5 grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center" }, /* @__PURE__ */ React.createElement("input", { value: tagForm.data.name, onChange: (event) => tagForm.setData("name", event.target.value), placeholder: "Tag name", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("input", { value: tagForm.data.slug, onChange: (event) => tagForm.setData("slug", event.target.value), placeholder: "optional slug", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("button", { type: "submit", className: "rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100" }, "Create tag")), /* @__PURE__ */ React.createElement("div", { className: "mt-6 grid gap-3" }, tags.map((tag, index2) => /* @__PURE__ */ React.createElement("div", { key: tag.id, className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] md:items-center" }, /* @__PURE__ */ React.createElement("input", { value: tag.name, onChange: (event) => updateTag(index2, "name", event.target.value), className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("input", { value: tag.slug, onChange: (event) => updateTag(index2, "slug", event.target.value), className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" }), /* @__PURE__ */ React.createElement("span", { className: "text-xs uppercase tracking-[0.14em] text-slate-500" }, Number(tag.published_count || 0).toLocaleString(), " published"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => saveTag(tag), className: "rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white" }, "Save")))))))); } -const __vite_glob_0_134 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_146 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioNewsTaxonomies }, Symbol.toStringTag, { value: "Module" })); @@ -102684,7 +106231,7 @@ function StudioPreferences() { return /* @__PURE__ */ React.createElement("div", { key: widgetKey, className: "flex flex-col gap-3 rounded-[22px] border border-white/10 bg-black/20 p-4 md:flex-row md:items-center md:justify-between" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold text-white" }, option.label), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs uppercase tracking-[0.16em] text-slate-500" }, "Position ", index2 + 1)), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => toggleWidget(widgetKey), className: `rounded-full border px-3 py-1.5 text-xs ${enabled ? "border-sky-300/25 bg-sky-300/10 text-sky-100" : "border-white/10 text-slate-300"}` }, enabled ? "Visible" : "Hidden"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => moveWidget(widgetKey, "up"), className: "rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-300" }, "Up"), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => moveWidget(widgetKey, "down"), className: "rounded-full border border-white/10 px-3 py-1.5 text-xs text-slate-300" }, "Down"))); })))), /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Related surfaces"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, (props.links || []).map((link2) => /* @__PURE__ */ React.createElement("a", { key: link2.url, href: link2.url, className: "block rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 text-sky-100" }, /* @__PURE__ */ React.createElement("i", { className: link2.icon }), /* @__PURE__ */ React.createElement("span", { className: "text-base font-semibold text-white" }, link2.label)))))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Preference notes"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3 text-sm text-slate-400" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-black/20 p-4" }, "Landing page and widget order are stored in the shared Studio preference record, so new Creator Studio surfaces can plug into the same contract without another migration."), /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-white/10 bg-black/20 p-4" }, "Analytics range and card density stay here so Analytics, Growth, and the main dashboard can stay visually consistent.")))))); } -const __vite_glob_0_135 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_147 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioPreferences }, Symbol.toStringTag, { value: "Module" })); @@ -102864,7 +106411,7 @@ function StudioProfile() { /* @__PURE__ */ React.createElement("div", { className: "p-6 pt-0" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-end gap-4" }, /* @__PURE__ */ React.createElement("div", { className: "relative" }, profile.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: profile.avatar_url, alt: profile.username, className: "h-24 w-24 rounded-[28px] border border-white/10 object-cover shadow-lg" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-24 w-24 items-center justify-center rounded-[28px] border border-white/10 bg-black/30 text-slate-400 shadow-lg" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-user text-2xl" })), /* @__PURE__ */ React.createElement("input", { ref: avatarInputRef, type: "file", accept: "image/png,image/jpeg,image/webp", onChange: handleAvatarSelected, className: "hidden" }), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => avatarInputRef.current?.click(), disabled: uploadingAvatar, className: "absolute -bottom-2 -right-2 inline-flex h-10 w-10 items-center justify-center rounded-full border border-sky-300/25 bg-sky-300/15 text-sky-100 disabled:opacity-50" }, /* @__PURE__ */ React.createElement("i", { className: `fa-solid ${uploadingAvatar ? "fa-spinner fa-spin" : "fa-camera"}` }))), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-3xl font-semibold text-white" }, profile.name), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-300" }, "@", profile.username), /* @__PURE__ */ React.createElement("div", { className: "mt-2 flex flex-wrap gap-4 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", null, Number(profile.followers || 0).toLocaleString(), " followers"), profile.location && /* @__PURE__ */ React.createElement("span", null, profile.location)))), profile.cover_url && /* @__PURE__ */ React.createElement("div", { className: "w-full max-w-sm rounded-[24px] border border-white/10 bg-black/30 p-4" }, /* @__PURE__ */ React.createElement("label", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400" }, "Banner position"), /* @__PURE__ */ React.createElement("input", { type: "range", min: "0", max: "100", value: coverPosition, onChange: (event) => setCoverPosition(Number(event.target.value)), className: "mt-3 w-full" }), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: saveCoverPosition, disabled: savingCoverPosition, className: "mt-3 inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-white disabled:opacity-50" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrows-up-down" }), savingCoverPosition ? "Saving..." : "Save banner position")))) )), /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Public profile details"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Update the creator information that supports your public presence across Nova.")), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: saveProfile, disabled: savingProfile, className: "inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 disabled:opacity-50" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-floppy-disk" }), savingProfile ? "Saving..." : "Save profile")), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-4 md:grid-cols-2" }, /* @__PURE__ */ React.createElement("label", { className: "space-y-2 text-sm text-slate-300 md:col-span-2" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Display name"), /* @__PURE__ */ React.createElement("input", { value: form.display_name, onChange: (event) => setForm((current) => ({ ...current, display_name: event.target.value })), className: "w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" })), /* @__PURE__ */ React.createElement("label", { className: "space-y-2 text-sm text-slate-300 md:col-span-2" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Tagline"), /* @__PURE__ */ React.createElement("input", { value: form.tagline, onChange: (event) => setForm((current) => ({ ...current, tagline: event.target.value })), placeholder: "One-line creator summary", className: "w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500" })), /* @__PURE__ */ React.createElement("label", { className: "space-y-2 text-sm text-slate-300 md:col-span-2" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Bio"), /* @__PURE__ */ React.createElement("textarea", { value: form.bio, onChange: (event) => setForm((current) => ({ ...current, bio: event.target.value })), rows: 5, placeholder: "Tell visitors what you create and what makes your work distinct.", className: "w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500" })), /* @__PURE__ */ React.createElement("label", { className: "space-y-2 text-sm text-slate-300 md:col-span-2" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Website"), /* @__PURE__ */ React.createElement("input", { value: form.website, onChange: (event) => setForm((current) => ({ ...current, website: event.target.value })), placeholder: "https://example.com", className: "w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500" }))), /* @__PURE__ */ React.createElement("div", { className: "mt-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("h3", { className: "text-base font-semibold text-white" }, "Social links"), /* @__PURE__ */ React.createElement("p", { className: "mt-1 text-sm text-slate-400" }, "Add the channels that matter for your creator identity.")), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: addSocialLink, className: "inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-white" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-plus" }), "Add link")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, form.social_links.map((link2, index2) => /* @__PURE__ */ React.createElement("div", { key: `${index2}-${link2.platform}`, className: "grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4 md:grid-cols-[180px_minmax(0,1fr)_auto]" }, /* @__PURE__ */ React.createElement("input", { value: link2.platform, onChange: (event) => updateSocialLink(index2, "platform", event.target.value), placeholder: "instagram", className: "rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500" }), /* @__PURE__ */ React.createElement("input", { value: link2.url, onChange: (event) => updateSocialLink(index2, "url", event.target.value), placeholder: "https://...", className: "rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500" }), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => removeSocialLink(index2), className: "inline-flex items-center justify-center rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-trash" }))))))), /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Publishing footprint"), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-4" }, (props.moduleSummaries || []).map((item) => /* @__PURE__ */ React.createElement("div", { key: item.key, className: "rounded-[22px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 text-slate-200" }, /* @__PURE__ */ React.createElement("i", { className: item.icon }), /* @__PURE__ */ React.createElement("span", null, item.label)), /* @__PURE__ */ React.createElement("div", { className: "mt-3 text-3xl font-semibold text-white" }, Number(item.count || 0).toLocaleString()), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-slate-400" }, Number(item.published_count || 0).toLocaleString(), " published, ", Number(item.draft_count || 0).toLocaleString(), " drafts"))))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-4" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Featured identity"), /* @__PURE__ */ React.createElement("a", { href: "/studio/featured", className: "text-sm font-medium text-sky-100" }, "Manage featured")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, featuredModules.length > 0 ? featuredModules.map((module) => /* @__PURE__ */ React.createElement("span", { key: module, className: "inline-flex items-center rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-sky-100" }, socialPlatformLabel(module))) : /* @__PURE__ */ React.createElement("p", { className: "text-sm text-slate-400" }, "No featured modules selected yet.")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, Object.entries(featuredContent).map(([module, item]) => item ? /* @__PURE__ */ React.createElement("a", { key: module, href: item.view_url || item.preview_url || "/studio/featured", className: "flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 p-3" }, item.image_url ? /* @__PURE__ */ React.createElement("img", { src: item.image_url, alt: item.title, className: "h-14 w-14 rounded-2xl object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-slate-400" }, /* @__PURE__ */ React.createElement("i", { className: item.module_icon || "fa-solid fa-star" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, socialPlatformLabel(module)), /* @__PURE__ */ React.createElement("div", { className: "truncate text-sm font-semibold text-white" }, item.title))) : null))))))); } -const __vite_glob_0_136 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_148 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioProfile }, Symbol.toStringTag, { value: "Module" })); @@ -102941,7 +106488,7 @@ function StudioScheduled() { }, [items, summary.next_publish_at]); return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("section", { className: "grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-3" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Scheduled total"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-3xl font-semibold text-white" }, Number(summary.total || 0).toLocaleString())), /* @__PURE__ */ React.createElement("div", { className: "rounded-[24px] border border-white/10 bg-black/20 p-4 md:col-span-2" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Next publish slot"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-xl font-semibold text-white" }, formatReleaseCountdown(summary.next_publish_at, nowMs)), summary.next_publish_at && /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-sm text-slate-400" }, formatScheduledDate(summary.next_publish_at)))), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4" }, (summary.by_module || []).map((entry) => /* @__PURE__ */ React.createElement("div", { key: entry.key, className: "rounded-[22px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 text-slate-300" }, /* @__PURE__ */ React.createElement("i", { className: entry.icon }), /* @__PURE__ */ React.createElement("span", { className: "text-sm font-medium text-white" }, entry.label)), /* @__PURE__ */ React.createElement("div", { className: "mt-3 text-2xl font-semibold text-white" }, Number(entry.count || 0).toLocaleString()))))), /* @__PURE__ */ React.createElement("div", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Agenda"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, agenda.length > 0 ? agenda.slice(0, 6).map((day) => /* @__PURE__ */ React.createElement("div", { key: day.date, className: "rounded-[22px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("span", { className: "text-sm font-semibold text-white" }, day.label), /* @__PURE__ */ React.createElement("span", { className: "text-xs uppercase tracking-[0.18em] text-slate-500" }, day.count, " items")), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-sm text-slate-400" }, day.items.slice(0, 2).map((item) => item.title).join(" • ")))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[22px] border border-dashed border-white/15 px-4 py-8 text-sm text-slate-400" }, "No scheduled items yet.")))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 lg:p-6" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 md:grid-cols-2 xl:grid-cols-5" }, /* @__PURE__ */ React.createElement("label", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Search scheduled work"), /* @__PURE__ */ React.createElement("input", { value: filters.q || "", onChange: (event) => updateFilters({ q: event.target.value }), className: "w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white", placeholder: "Title or module" })), /* @__PURE__ */ React.createElement("div", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Module"), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.module || "all", onChange: (val) => updateFilters({ module: val }), options: listing.module_options || [], searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Date range"), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.range || "upcoming", onChange: (val) => updateFilters({ range: val }), options: rangeOptions2, searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Start date"), /* @__PURE__ */ React.createElement(DateTimePicker, { value: filters.start_date || "", onChange: (nextValue) => updateFilters({ range: "custom", start_date: nextValue }), mode: "date", placeholder: "Start date", clearable: true, className: "bg-black/20" })), /* @__PURE__ */ React.createElement("div", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "End date"), /* @__PURE__ */ React.createElement(DateTimePicker, { value: filters.end_date || "", onChange: (nextValue) => updateFilters({ range: "custom", end_date: nextValue }), mode: "date", placeholder: "End date", clearable: true, className: "bg-black/20" })), /* @__PURE__ */ React.createElement("div", { className: "flex items-end" }, /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => updateFilters({ q: "", module: "all", range: "upcoming", start_date: "", end_date: "" }), className: "w-full rounded-2xl border border-white/10 px-4 py-3 text-sm text-slate-200" }, "Reset")))), /* @__PURE__ */ React.createElement("section", { className: "space-y-4" }, items.length > 0 ? items.map((item) => /* @__PURE__ */ React.createElement("article", { key: item.id, className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between" }, /* @__PURE__ */ React.createElement("div", { className: "min-w-0" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/70" }, /* @__PURE__ */ React.createElement("span", null, item.module_label), /* @__PURE__ */ React.createElement("span", null, item.status)), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-xl font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement("div", { className: "mt-2 flex flex-wrap items-center gap-4 text-sm text-slate-400" }, /* @__PURE__ */ React.createElement("span", null, formatReleaseCountdown(item.scheduled_at || item.published_at, nowMs)), /* @__PURE__ */ React.createElement("span", null, formatScheduledDate(item.scheduled_at || item.published_at)), item.visibility && /* @__PURE__ */ React.createElement("span", null, "Visibility: ", item.visibility), item.updated_at && /* @__PURE__ */ React.createElement("span", null, "Last edited ", formatScheduledDate(item.updated_at)), item.schedule_timezone && /* @__PURE__ */ React.createElement("span", null, item.schedule_timezone))), /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("a", { href: item.edit_url || item.manage_url, className: "inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-slate-200" }, "Edit"), /* @__PURE__ */ React.createElement("a", { href: item.edit_url || item.manage_url, className: "inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-slate-200" }, "Reschedule"), item.preview_url && /* @__PURE__ */ React.createElement("a", { href: item.preview_url, className: "inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-slate-200" }, "Preview"), /* @__PURE__ */ React.createElement("button", { type: "button", disabled: busyId === `publish:${item.id}`, onClick: () => runAction(item, "publish"), className: "inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm text-sky-100 disabled:opacity-50" }, "Publish now"), /* @__PURE__ */ React.createElement("button", { type: "button", disabled: busyId === `unschedule:${item.id}`, onClick: () => runAction(item, "unschedule"), className: "inline-flex items-center gap-2 rounded-full border border-white/10 px-4 py-2 text-sm text-slate-200 disabled:opacity-50" }, "Unschedule"))))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-dashed border-white/15 px-6 py-16 text-center text-slate-400" }, "No scheduled content matches this view.")), /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("button", { type: "button", disabled: (meta.current_page || 1) <= 1, onClick: () => updateFilters({ page: Math.max(1, (meta.current_page || 1) - 1) }), className: "rounded-full border border-white/10 px-4 py-2 disabled:opacity-40" }, "Previous"), /* @__PURE__ */ React.createElement("span", { className: "text-xs uppercase tracking-[0.18em] text-slate-500" }, "Page ", meta.current_page || 1, " of ", meta.last_page || 1), /* @__PURE__ */ React.createElement("button", { type: "button", disabled: (meta.current_page || 1) >= (meta.last_page || 1), onClick: () => updateFilters({ page: (meta.current_page || 1) + 1 }), className: "rounded-full border border-white/10 px-4 py-2 disabled:opacity-40" }, "Next")))); } -const __vite_glob_0_137 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_149 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioScheduled }, Symbol.toStringTag, { value: "Module" })); @@ -102959,7 +106506,7 @@ function StudioSearch() { }; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_35%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 lg:p-6" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-3 md:grid-cols-2 xl:grid-cols-5" }, /* @__PURE__ */ React.createElement("label", { className: "space-y-2 text-sm text-slate-300 xl:col-span-3" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Search Studio"), /* @__PURE__ */ React.createElement("input", { value: filters.q || "", onChange: (event) => updateFilters({ q: event.target.value }), className: "w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white", placeholder: "Search content, comments, inbox, or assets" })), /* @__PURE__ */ React.createElement("div", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Surface"), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.type || "all", onChange: (val) => updateFilters({ type: val }), options: search2.type_options || [], searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "space-y-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, "Module"), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.module || "all", onChange: (val) => updateFilters({ module: val }), options: search2.module_options || [], searchable: false })))), filters.q ? /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("div", { className: "text-sm text-slate-400" }, "Found ", /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, Number(search2.summary?.total || 0).toLocaleString()), " matches for ", /* @__PURE__ */ React.createElement("span", { className: "font-semibold text-white" }, search2.summary?.query)), sections.length > 0 ? sections.map((section) => /* @__PURE__ */ React.createElement("section", { key: section.key, className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, section.label), /* @__PURE__ */ React.createElement("span", { className: "text-xs uppercase tracking-[0.18em] text-slate-500" }, section.count, " matches")), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3" }, section.items.map((item) => /* @__PURE__ */ React.createElement("a", { key: item.id, href: item.href, className: "block rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start gap-3" }, /* @__PURE__ */ React.createElement("div", { className: "flex h-10 w-10 items-center justify-center rounded-2xl bg-white/[0.04] text-sky-100" }, /* @__PURE__ */ React.createElement("i", { className: item.icon })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0" }, /* @__PURE__ */ React.createElement("div", { className: "truncate text-base font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs uppercase tracking-[0.18em] text-slate-500" }, item.subtitle), /* @__PURE__ */ React.createElement("p", { className: "mt-3 line-clamp-3 text-sm leading-6 text-slate-400" }, item.description)))))))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-dashed border-white/15 px-6 py-16 text-center text-slate-400" }, "No results matched this search yet.")) : /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Continue working"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3 md:grid-cols-2" }, (search2.empty_state?.continue_working || []).map((item) => /* @__PURE__ */ React.createElement("a", { key: item.id, href: item.edit_url || item.manage_url, className: "block rounded-[24px] border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs text-slate-500" }, item.module_label, " · ", item.workflow?.readiness?.label))))), /* @__PURE__ */ React.createElement("aside", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Stale drafts"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 space-y-3" }, (search2.empty_state?.stale_drafts || []).map((item) => /* @__PURE__ */ React.createElement("a", { key: item.id, href: item.edit_url || item.manage_url, className: "block rounded-2xl border border-white/10 bg-black/20 p-4" }, /* @__PURE__ */ React.createElement("div", { className: "text-sm font-semibold text-white" }, item.title), /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-xs text-slate-500" }, item.module_label))))), /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "Quick create"), /* @__PURE__ */ React.createElement("div", { className: "mt-4 grid gap-3" }, (props.quickCreate || []).map((item) => /* @__PURE__ */ React.createElement("a", { key: item.key, href: item.url, className: "inline-flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-100" }, /* @__PURE__ */ React.createElement("i", { className: item.icon }), /* @__PURE__ */ React.createElement("span", null, "New ", item.label))))))))); } -const __vite_glob_0_138 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_150 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioSearch }, Symbol.toStringTag, { value: "Module" })); @@ -102967,7 +106514,7 @@ function StudioSettings() { const { props } = X$1(); return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]" }, /* @__PURE__ */ React.createElement("section", { className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, "System handoff"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-2xl text-sm leading-6 text-slate-400" }, "Studio now keeps creator workflow preferences in their own surface. This page stays focused on links out to adjacent dashboards and the control points that do not belong in the day-to-day workflow UI."), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-3 md:grid-cols-2" }, (props.links || []).map((link2) => /* @__PURE__ */ React.createElement("a", { key: link2.url, href: link2.url, className: "rounded-[22px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20 hover:bg-black/30" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 text-sky-100" }, /* @__PURE__ */ React.createElement("i", { className: link2.icon }), /* @__PURE__ */ React.createElement("span", { className: "text-base font-semibold text-white" }, link2.label)), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-slate-400" }, "Open the linked dashboard or settings surface without losing the Studio navigation shell as the default control plane."))))), /* @__PURE__ */ React.createElement("section", { className: "space-y-6" }, (props.sections || []).map((section) => /* @__PURE__ */ React.createElement("div", { key: section.title, className: "rounded-[30px] border border-white/10 bg-white/[0.03] p-6" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold text-white" }, section.title), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6 text-slate-400" }, section.body), /* @__PURE__ */ React.createElement("a", { href: section.href, className: "mt-4 inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15" }, section.cta, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-right" }))))))); } -const __vite_glob_0_139 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_151 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioSettings }, Symbol.toStringTag, { value: "Module" })); @@ -102980,7 +106527,7 @@ function StudioStories() { ["Published", summary.published_count, "fa-solid fa-sparkles"] ].map(([label, value, icon]) => /* @__PURE__ */ React.createElement("div", { key: label, className: "rounded-[24px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 text-slate-300" }, /* @__PURE__ */ React.createElement("i", { className: icon }), /* @__PURE__ */ React.createElement("span", { className: "text-sm" }, label)), /* @__PURE__ */ React.createElement("div", { className: "mt-3 text-3xl font-semibold text-white" }, Number(value || 0).toLocaleString()))), /* @__PURE__ */ React.createElement("a", { href: props.dashboardUrl, className: "rounded-[24px] border border-sky-300/20 bg-sky-300/10 p-5 text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15" }, /* @__PURE__ */ React.createElement("p", { className: "text-[11px] font-semibold uppercase tracking-[0.2em]" }, "Story dashboard"), /* @__PURE__ */ React.createElement("p", { className: "mt-3 text-sm leading-6" }, "Jump into the existing story workspace when you need the full editor and publishing controls."))), /* @__PURE__ */ React.createElement(StudioContentBrowser, { listing: props.listing, quickCreate: props.quickCreate, hideModuleFilter: true })); } -const __vite_glob_0_140 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_152 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioStories }, Symbol.toStringTag, { value: "Module" })); @@ -103643,7 +107190,7 @@ function StudioUploadQueue() { ))))); }))))); } -const __vite_glob_0_141 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_153 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioUploadQueue }, Symbol.toStringTag, { value: "Module" })); @@ -105375,7 +108922,7 @@ function StudioWorldEditor() { )) : null )); } -const __vite_glob_0_142 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_154 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioWorldEditor }, Symbol.toStringTag, { value: "Module" })); @@ -105461,7 +109008,7 @@ function StudioWorldsIndex() { }; return /* @__PURE__ */ React.createElement(StudioLayout, { title: props.title, subtitle: props.description }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-6" }, /* @__PURE__ */ React.createElement(WorldAnalyticsPortfolioPanel, { analytics: props.analytics }), /* @__PURE__ */ React.createElement("section", { className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 lg:grid-cols-[minmax(0,1fr)_12rem_12rem_auto] lg:items-end" }, /* @__PURE__ */ React.createElement("label", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Search"), /* @__PURE__ */ React.createElement("input", { value: filters.q || "", onChange: (event) => updateFilter("q", event.target.value), placeholder: "Search title, slug, or summary", className: "rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Status"), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.status || "", onChange: (val) => updateFilter("status", val), options: [{ value: "", label: "All statuses" }, ...props.statusOptions || []], searchable: false })), /* @__PURE__ */ React.createElement("div", { className: "grid gap-2 text-sm text-slate-300" }, /* @__PURE__ */ React.createElement("span", { className: "text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500" }, "Type"), /* @__PURE__ */ React.createElement(NovaSelect, { value: filters.type || "", onChange: (val) => updateFilter("type", val), options: [{ value: "", label: "All types" }, ...props.typeOptions || []], searchable: false })), /* @__PURE__ */ React.createElement("a", { href: props.createUrl, className: "inline-flex items-center justify-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-plus" }), "New world"))), /* @__PURE__ */ React.createElement("section", { className: "grid gap-4 xl:grid-cols-2" }, items.length > 0 ? items.map((world) => /* @__PURE__ */ React.createElement("a", { key: world.id, href: world.edit_url, className: "rounded-[28px] border border-white/10 bg-white/[0.03] p-5 transition hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500" }, /* @__PURE__ */ React.createElement(WorldStatusBadge, { badge: { label: world.status, tone: "slate" } }), /* @__PURE__ */ React.createElement(WorldStatusBadge, { badge: { label: world.type, tone: "slate" } }), (Array.isArray(world.status_badges) ? world.status_badges : []).map((badge) => /* @__PURE__ */ React.createElement(WorldStatusBadge, { key: `${world.id}-${badge.label}`, badge }))), /* @__PURE__ */ React.createElement("h2", { className: "mt-4 text-2xl font-semibold tracking-[-0.03em] text-white" }, world.title), /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-sm text-slate-500" }, "/", world.slug), world.summary ? /* @__PURE__ */ React.createElement("p", { className: "mt-4 text-sm leading-6 text-slate-300" }, world.summary) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-4 text-sm text-slate-400" }, world.timeframe_label ? /* @__PURE__ */ React.createElement("span", null, world.timeframe_label) : null, world.promotion_window_label ? /* @__PURE__ */ React.createElement("span", null, world.promotion_window_label) : null, /* @__PURE__ */ React.createElement("span", null, world.relation_count, " relations"), world.live_submission_count > 0 ? /* @__PURE__ */ React.createElement("span", null, world.live_submission_count, " live submissions") : null, world.theme_key ? /* @__PURE__ */ React.createElement("span", null, world.theme_key) : null), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-3 text-sm font-semibold" }, /* @__PURE__ */ React.createElement("span", { className: "text-sky-100" }, "Edit"), /* @__PURE__ */ React.createElement("span", { className: "text-slate-500" }, "Preview"), world.public_url ? /* @__PURE__ */ React.createElement("span", { className: "text-slate-500" }, "Public") : null))) : /* @__PURE__ */ React.createElement("div", { className: "rounded-[28px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400" }, "No worlds match this filter yet.")))); } -const __vite_glob_0_143 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_155 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: StudioWorldsIndex }, Symbol.toStringTag, { value: "Module" })); @@ -110068,7 +113615,7 @@ function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkRequestTimeoutMs }) "Reset" )))), /* @__PURE__ */ React.createElement("div", { className: "space-y-6" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/5 p-6" }, /* @__PURE__ */ React.createElement("h3", { className: "text-lg font-semibold" }, "Pipeline status"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-white/60" }, "Stage: ", /* @__PURE__ */ React.createElement("span", { className: "text-white" }, statusLabel2)), /* @__PURE__ */ React.createElement("div", { className: "mt-4 h-2 w-full overflow-hidden rounded-full bg-white/10" }, /* @__PURE__ */ React.createElement("div", { className: "h-full bg-sky-400 transition-all", style: { width: `${state.progress}%` } })), state.failureReason && /* @__PURE__ */ React.createElement("div", { className: "mt-3 text-sm text-red-200" }, "Failure: ", state.failureReason), state.previewUrl && state.phase === phases.success && /* @__PURE__ */ React.createElement("div", { className: "mt-6" }, /* @__PURE__ */ React.createElement("h4", { className: "text-sm font-semibold text-white/80" }, "CDN preview"), /* @__PURE__ */ React.createElement("div", { className: "mt-2 overflow-hidden rounded-xl border border-white/10" }, /* @__PURE__ */ React.createElement("img", { src: state.previewUrl, alt: "CDN preview", className: "h-56 w-full object-cover" }))), /* @__PURE__ */ React.createElement("div", { className: "mt-6 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-xs text-white/60" }, "Session: ", state.sessionId ?? "—")), /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-white/10 bg-white/5 p-6" }, /* @__PURE__ */ React.createElement("h3", { className: "text-lg font-semibold" }, "Draft resume"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm text-white/60" }, "Use the draft link to resume an interrupted upload."), draftId ? /* @__PURE__ */ React.createElement("div", { className: "mt-4 text-sm text-white/80" }, "Draft ID: ", draftId) : /* @__PURE__ */ React.createElement("div", { className: "mt-4 text-sm text-white/50" }, "No draft loaded.")))))); } -const __vite_glob_0_144 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_156 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: UploadPage }, Symbol.toStringTag, { value: "Module" })); @@ -110312,7 +113859,7 @@ function WorldIndex() { } ))); } -const __vite_glob_0_145 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_157 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: WorldIndex }, Symbol.toStringTag, { value: "Module" })); @@ -110512,6 +114059,12 @@ function RewardedContributors({ section, world }) { } return /* @__PURE__ */ React.createElement("section", { className: "mt-10 rounded-[28px] border border-white/10 bg-white/[0.03] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "mb-5" }, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold tracking-[-0.03em] text-white" }, "Rewarded Contributors"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-3xl text-sm leading-6 text-slate-400" }, "Creators who earned visible recognition in this edition. Live participation builds history here, while featured and editorial selections raise the level of recognition.")), summaryChips.length > 0 || rewardTypeChips.length > 0 || world?.cta_url ? /* @__PURE__ */ React.createElement("div", { className: "mb-5 rounded-[24px] border border-white/10 bg-black/20 p-4" }, summaryChips.length > 0 ? /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap gap-2" }, summaryChips.map((item) => /* @__PURE__ */ React.createElement("span", { key: item, className: "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-200" }, item))) : null, rewardTypeChips.length > 0 ? /* @__PURE__ */ React.createElement("div", { className: "mt-3 flex flex-wrap gap-2" }, rewardTypeChips.map((item) => /* @__PURE__ */ React.createElement("span", { key: item, className: "rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-sky-100" }, item))) : null, world?.cta_url ? /* @__PURE__ */ React.createElement("div", { className: "mt-4" }, /* @__PURE__ */ React.createElement("a", { href: world.cta_url, "data-world-event": "world_cta_clicked", "data-world-section-key": "rewards", "data-world-cta-key": "rewards_join_world", className: "inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.06] px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.1]" }, world.cta_label || "Join this world", /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-right" }))) : null) : null, /* @__PURE__ */ React.createElement("div", { className: "mb-5" }, /* @__PURE__ */ React.createElement("p", { className: "max-w-3xl text-sm leading-6 text-slate-400" }, "This edition’s rewards are edition-aware, so recognition here remains part of each creator’s recurring world history.")), /* @__PURE__ */ React.createElement("div", { className: "grid gap-4 md:grid-cols-2 xl:grid-cols-3" }, items.map((item) => /* @__PURE__ */ React.createElement("a", { key: item.id, href: item.creator?.profile_url || item.world?.url || "#", "data-world-event": "world_entity_clicked", "data-world-section-key": "rewards", "data-world-entity-type": "creator", "data-world-entity-id": item.creator?.id || 0, "data-world-entity-title": item.creator?.name || item.creator?.username || "Creator", className: "rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/15 hover:bg-white/[0.06]" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3" }, item.creator?.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: item.creator.avatar_url, alt: item.creator.username || item.creator.name, className: "h-12 w-12 rounded-2xl object-cover ring-1 ring-white/10" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-500" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-user" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "truncate text-sm font-semibold text-white" }, item.creator?.name || item.creator?.username || "Creator"), /* @__PURE__ */ React.createElement("div", { className: "truncate text-xs uppercase tracking-[0.16em] text-slate-500" }, item.badge_label))), item.artwork?.title ? /* @__PURE__ */ React.createElement("div", { className: "mt-3 text-sm text-slate-300" }, item.artwork.title) : null)))); } +function WebStoryCard({ story, worldTitle }) { + if (!story) { + return null; + } + return /* @__PURE__ */ React.createElement("section", { className: "mt-10 rounded-[28px] border border-sky-300/18 bg-[linear-gradient(135deg,rgba(14,165,233,0.12),rgba(15,23,42,0.85))] p-5" }, /* @__PURE__ */ React.createElement("div", { className: "grid gap-5 lg:grid-cols-[220px,1fr] lg:items-center" }, /* @__PURE__ */ React.createElement("div", { className: "overflow-hidden rounded-[24px] border border-white/10 bg-black/20 aspect-[3/4] max-w-[220px]" }, story.poster_portrait_url ? /* @__PURE__ */ React.createElement("img", { src: story.poster_portrait_url, alt: story.title, className: "h-full w-full object-cover" }) : /* @__PURE__ */ React.createElement("div", { className: "flex h-full items-center justify-center text-slate-500" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-book-open-reader text-4xl" }))), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80" }, "Web Story"), /* @__PURE__ */ React.createElement("h2", { className: "mt-3 text-2xl font-semibold tracking-[-0.03em] text-white" }, "Experience this World as a Web Story"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-3xl text-sm leading-6 text-slate-200" }, "Swipe through a cinematic visual preview of ", worldTitle, "."), story.excerpt ? /* @__PURE__ */ React.createElement("p", { className: "mt-3 max-w-3xl text-sm leading-6 text-slate-300" }, story.excerpt) : null, /* @__PURE__ */ React.createElement("div", { className: "mt-5" }, /* @__PURE__ */ React.createElement("a", { href: story.url, className: "inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.08] px-5 py-2.5 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.14]" }, "View Web Story", /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-arrow-right" })))))); +} function WorldShow() { const { props } = X$1(); const world = props.world; @@ -110529,6 +114082,7 @@ function WorldShow() { const currentEdition = props.currentEdition || null; const previousEdition = props.previousEdition || null; const nextEdition = props.nextEdition || null; + const webStory = props.webStory || world?.published_web_story || null; const archiveTitle = currentEdition ? "Previous Editions" : "Archive Editions"; const archiveDescription = currentEdition ? "Earlier editions remain public so the recurring family keeps its full history accessible." : "Past iterations remain accessible so recurring worlds can build continuity over time."; const rootRef = reactExports.useRef(null); @@ -110580,7 +114134,7 @@ function WorldShow() { container.removeEventListener("click", clickHandler); }; }, [previewMode, world?.id]); - return /* @__PURE__ */ React.createElement("main", { ref: rootRef, className: "min-h-screen bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.12),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { title: props.seo?.title || `${world?.title || "World"} - Skinbase`, description: props.seo?.description || world?.summary, image: props.seo?.image }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-7xl" }, previewMode ? /* @__PURE__ */ React.createElement("section", { className: "mb-6 rounded-[28px] border border-amber-300/20 bg-amber-400/10 px-5 py-4 text-sm text-amber-50" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100/75" }, "Studio preview"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, "You are viewing the editorial preview version of this world before or alongside public release.")), world?.public_url ? /* @__PURE__ */ React.createElement("a", { href: world.public_url, className: "inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white" }, "Open canonical page ", /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-up-right-from-square" })) : null)) : null, /* @__PURE__ */ React.createElement(WorldArchiveNotice, { notice: archiveNotice }), recap ? /* @__PURE__ */ React.createElement(WorldRecapHero, { world, recap, previewMode }) : /* @__PURE__ */ React.createElement(WorldHero, { world, previewMode }), recap ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(WorldRecapSummaryCard, { recap }), /* @__PURE__ */ React.createElement(WorldRecapStatsGrid, { stats: recap.stats }), /* @__PURE__ */ React.createElement(WorldRecapArticleCard, { article: recap.article }), /* @__PURE__ */ React.createElement(WorldRecapFeaturedArtworks, { section: recap.featured_artworks }), /* @__PURE__ */ React.createElement(WorldChallengePanel, { section: linkedChallenge })) : /* @__PURE__ */ React.createElement(WorldChallengePanel, { section: linkedChallenge }), familySummary ? /* @__PURE__ */ React.createElement("section", { className: "mt-10" }, /* @__PURE__ */ React.createElement("div", { className: "mb-5" }, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold tracking-[-0.03em] text-white" }, "Recurring Family"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-3xl text-sm leading-6 text-slate-400" }, "Each edition stays public, but the family route always resolves to the canonical current or latest edition.")), /* @__PURE__ */ React.createElement(WorldFamilyCard, { family: familySummary, sourceSurface: "navigation", sourceDetail: "family_summary" })) : null, sections.length > 0 ? sections.map((section) => /* @__PURE__ */ React.createElement(WorldSection, { key: section.key, section })) : null, /* @__PURE__ */ React.createElement(WorldChallengeEntriesRail, { section: linkedChallengeEntries, challengeId: linkedChallenge?.id || null }), /* @__PURE__ */ React.createElement(WorldChallengeWinnersPanel, { section: linkedChallengeWinners, challengeId: linkedChallenge?.id || null }), /* @__PURE__ */ React.createElement(WorldChallengeFinalistsGrid, { panel: linkedChallenge, section: linkedChallengeFinalists }), recap ? /* @__PURE__ */ React.createElement(WorldRecapCommunityHighlights, { section: recap.community_highlights }) : /* @__PURE__ */ React.createElement(RewardedContributors, { section: rewardedContributors, world }), recap ? /* @__PURE__ */ React.createElement(WorldRecapCreatorsPanel, { section: recap.creators }) : /* @__PURE__ */ React.createElement(WorldCommunitySubmissionsSection, { section: communitySubmissions }), currentEdition ? /* @__PURE__ */ React.createElement( + return /* @__PURE__ */ React.createElement("main", { ref: rootRef, className: "min-h-screen bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.12),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement(SeoHead, { title: props.seo?.title || `${world?.title || "World"} - Skinbase`, description: props.seo?.description || world?.summary, image: props.seo?.image }), /* @__PURE__ */ React.createElement("div", { className: "mx-auto max-w-7xl" }, previewMode ? /* @__PURE__ */ React.createElement("section", { className: "mb-6 rounded-[28px] border border-amber-300/20 bg-amber-400/10 px-5 py-4 text-sm text-amber-50" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-wrap items-center justify-between gap-3" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100/75" }, "Studio preview"), /* @__PURE__ */ React.createElement("div", { className: "mt-1 font-semibold text-white" }, "You are viewing the editorial preview version of this world before or alongside public release.")), world?.public_url ? /* @__PURE__ */ React.createElement("a", { href: world.public_url, className: "inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white" }, "Open canonical page ", /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-up-right-from-square" })) : null)) : null, /* @__PURE__ */ React.createElement(WorldArchiveNotice, { notice: archiveNotice }), recap ? /* @__PURE__ */ React.createElement(WorldRecapHero, { world, recap, previewMode }) : /* @__PURE__ */ React.createElement(WorldHero, { world, previewMode }), recap ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(WorldRecapSummaryCard, { recap }), /* @__PURE__ */ React.createElement(WorldRecapStatsGrid, { stats: recap.stats }), /* @__PURE__ */ React.createElement(WorldRecapArticleCard, { article: recap.article }), /* @__PURE__ */ React.createElement(WorldRecapFeaturedArtworks, { section: recap.featured_artworks }), /* @__PURE__ */ React.createElement(WorldChallengePanel, { section: linkedChallenge })) : /* @__PURE__ */ React.createElement(WorldChallengePanel, { section: linkedChallenge }), familySummary ? /* @__PURE__ */ React.createElement("section", { className: "mt-10" }, /* @__PURE__ */ React.createElement("div", { className: "mb-5" }, /* @__PURE__ */ React.createElement("h2", { className: "text-2xl font-semibold tracking-[-0.03em] text-white" }, "Recurring Family"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-3xl text-sm leading-6 text-slate-400" }, "Each edition stays public, but the family route always resolves to the canonical current or latest edition.")), /* @__PURE__ */ React.createElement(WorldFamilyCard, { family: familySummary, sourceSurface: "navigation", sourceDetail: "family_summary" })) : null, /* @__PURE__ */ React.createElement(WebStoryCard, { story: webStory, worldTitle: world?.title || "this World" }), sections.length > 0 ? sections.map((section) => /* @__PURE__ */ React.createElement(WorldSection, { key: section.key, section })) : null, /* @__PURE__ */ React.createElement(WorldChallengeEntriesRail, { section: linkedChallengeEntries, challengeId: linkedChallenge?.id || null }), /* @__PURE__ */ React.createElement(WorldChallengeWinnersPanel, { section: linkedChallengeWinners, challengeId: linkedChallenge?.id || null }), /* @__PURE__ */ React.createElement(WorldChallengeFinalistsGrid, { panel: linkedChallenge, section: linkedChallengeFinalists }), recap ? /* @__PURE__ */ React.createElement(WorldRecapCommunityHighlights, { section: recap.community_highlights }) : /* @__PURE__ */ React.createElement(RewardedContributors, { section: rewardedContributors, world }), recap ? /* @__PURE__ */ React.createElement(WorldRecapCreatorsPanel, { section: recap.creators }) : /* @__PURE__ */ React.createElement(WorldCommunitySubmissionsSection, { section: communitySubmissions }), currentEdition ? /* @__PURE__ */ React.createElement( SupportingRail, { title: "Current Edition", @@ -110606,7 +114160,7 @@ function WorldShow() { } ))); } -const __vite_glob_0_146 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const __vite_glob_0_158 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, default: WorldShow }, Symbol.toStringTag, { value: "Module" })); @@ -136197,153 +139751,165 @@ function requireServer_node() { var server_nodeExports = requireServer_node(); const ReactDOMServer = /* @__PURE__ */ getDefaultExportFromCjs(server_nodeExports); const pages = /* @__PURE__ */ Object.assign({ - "./Pages/Academy/ChallengeSubmit.jsx": __vite_glob_0_0, - "./Pages/Academy/CoursesIndex.jsx": __vite_glob_0_1, - "./Pages/Academy/CoursesShow.jsx": __vite_glob_0_2, - "./Pages/Academy/Index.jsx": __vite_glob_0_3, - "./Pages/Academy/List.jsx": __vite_glob_0_4, - "./Pages/Academy/Pricing.jsx": __vite_glob_0_5, - "./Pages/Academy/Show.jsx": __vite_glob_0_6, - "./Pages/Admin/Academy/CourseBuilder.jsx": __vite_glob_0_7, - "./Pages/Admin/Academy/CourseEditor.jsx": __vite_glob_0_8, - "./Pages/Admin/Academy/CrudForm.jsx": __vite_glob_0_9, - "./Pages/Admin/Academy/CrudIndex.jsx": __vite_glob_0_10, - "./Pages/Admin/Academy/Dashboard.jsx": __vite_glob_0_11, - "./Pages/Admin/Academy/LessonEditor.jsx": __vite_glob_0_12, - "./Pages/Admin/Academy/Submissions.jsx": __vite_glob_0_13, - "./Pages/Admin/AiBiography.jsx": __vite_glob_0_14, - "./Pages/Admin/Artworks.jsx": __vite_glob_0_15, - "./Pages/Admin/AuthAudit.jsx": __vite_glob_0_16, - "./Pages/Admin/DailyActivity.jsx": __vite_glob_0_17, - "./Pages/Admin/Dashboard.jsx": __vite_glob_0_18, - "./Pages/Admin/FeaturedArtworks.jsx": __vite_glob_0_19, - "./Pages/Admin/HomepageAnnouncements/Form.jsx": __vite_glob_0_20, - "./Pages/Admin/HomepageAnnouncements/Index.jsx": __vite_glob_0_21, - "./Pages/Admin/Settings.jsx": __vite_glob_0_22, - "./Pages/Admin/Stories.jsx": __vite_glob_0_23, - "./Pages/Admin/UploadQueue.jsx": __vite_glob_0_24, - "./Pages/Admin/UsernameQueue.jsx": __vite_glob_0_25, - "./Pages/Admin/Users/Index.jsx": __vite_glob_0_26, - "./Pages/Artwork/SimilarArtworksHeader.jsx": __vite_glob_0_27, - "./Pages/ArtworkPage.jsx": __vite_glob_0_28, - "./Pages/CategoriesPage.jsx": __vite_glob_0_29, - "./Pages/Collection/CollectionAnalytics.jsx": __vite_glob_0_30, - "./Pages/Collection/CollectionDashboard.jsx": __vite_glob_0_31, - "./Pages/Collection/CollectionFeaturedIndex.jsx": __vite_glob_0_32, - "./Pages/Collection/CollectionHistory.jsx": __vite_glob_0_33, - "./Pages/Collection/CollectionManage.jsx": __vite_glob_0_34, - "./Pages/Collection/CollectionSeriesShow.jsx": __vite_glob_0_35, - "./Pages/Collection/CollectionShow.jsx": __vite_glob_0_36, - "./Pages/Collection/CollectionStaffProgramming.jsx": __vite_glob_0_37, - "./Pages/Collection/CollectionStaffSurfaces.jsx": __vite_glob_0_38, - "./Pages/Collection/FeaturedArtworksAdmin.jsx": __vite_glob_0_39, - "./Pages/Collection/NovaCardsAdminIndex.jsx": __vite_glob_0_40, - "./Pages/Collection/NovaCardsAssetPackAdmin.jsx": __vite_glob_0_41, - "./Pages/Collection/NovaCardsChallengeAdmin.jsx": __vite_glob_0_42, - "./Pages/Collection/NovaCardsCollectionAdmin.jsx": __vite_glob_0_43, - "./Pages/Collection/NovaCardsTemplateAdmin.jsx": __vite_glob_0_44, - "./Pages/Collection/SavedCollections.jsx": __vite_glob_0_45, - "./Pages/Community/CommunityActivityPage.jsx": __vite_glob_0_46, - "./Pages/Community/LatestCommentsPage.jsx": __vite_glob_0_47, - "./Pages/Feed/FollowingFeed.jsx": __vite_glob_0_48, - "./Pages/Feed/HashtagFeed.jsx": __vite_glob_0_49, - "./Pages/Feed/SavedFeed.jsx": __vite_glob_0_50, - "./Pages/Feed/SearchFeed.jsx": __vite_glob_0_51, - "./Pages/Feed/TrendingFeed.jsx": __vite_glob_0_52, - "./Pages/Forum/ForumCategory.jsx": __vite_glob_0_53, - "./Pages/Forum/ForumEditPost.jsx": __vite_glob_0_54, - "./Pages/Forum/ForumIndex.jsx": __vite_glob_0_55, - "./Pages/Forum/ForumNewThread.jsx": __vite_glob_0_56, - "./Pages/Forum/ForumSection.jsx": __vite_glob_0_57, - "./Pages/Forum/ForumThread.jsx": __vite_glob_0_58, - "./Pages/Group/GroupChallengeShow.jsx": __vite_glob_0_59, - "./Pages/Group/GroupEventShow.jsx": __vite_glob_0_60, - "./Pages/Group/GroupFaqPage.jsx": __vite_glob_0_61, - "./Pages/Group/GroupHelpPage.jsx": __vite_glob_0_62, - "./Pages/Group/GroupIndex.jsx": __vite_glob_0_63, - "./Pages/Group/GroupPostShow.jsx": __vite_glob_0_64, - "./Pages/Group/GroupProjectShow.jsx": __vite_glob_0_65, - "./Pages/Group/GroupQuickstartPage.jsx": __vite_glob_0_66, - "./Pages/Group/GroupReleaseShow.jsx": __vite_glob_0_67, - "./Pages/Group/GroupShow.jsx": __vite_glob_0_68, - "./Pages/Help/AccountHelpPage.jsx": __vite_glob_0_69, - "./Pages/Help/AuthHelpPage.jsx": __vite_glob_0_70, - "./Pages/Help/CardsHelpPage.jsx": __vite_glob_0_71, - "./Pages/Help/HelpCenterPage.jsx": __vite_glob_0_72, - "./Pages/Help/ProfileHelpPage.jsx": __vite_glob_0_73, - "./Pages/Help/StudioHelpPage.jsx": __vite_glob_0_74, - "./Pages/Help/TroubleshootingHelpPage.jsx": __vite_glob_0_75, - "./Pages/Help/UploadHelpPage.jsx": __vite_glob_0_76, - "./Pages/Help/WorldsHelpPage.jsx": __vite_glob_0_77, - "./Pages/Leaderboard/LeaderboardPage.jsx": __vite_glob_0_78, - "./Pages/Messages/Index.jsx": __vite_glob_0_79, - "./Pages/Moderation/AiBiographyAdmin.jsx": __vite_glob_0_80, - "./Pages/Moderation/ArtworkMaturityQueue.jsx": __vite_glob_0_81, - "./Pages/News/NewsComments.jsx": __vite_glob_0_82, - "./Pages/News/NewsImagePreview.jsx": __vite_glob_0_83, - "./Pages/Profile/ProfileGallery.jsx": __vite_glob_0_84, - "./Pages/Profile/ProfileShow.jsx": __vite_glob_0_85, - "./Pages/Settings/ProfileEdit.jsx": __vite_glob_0_86, - "./Pages/Studio/StudioActivity.jsx": __vite_glob_0_87, - "./Pages/Studio/StudioAnalytics.jsx": __vite_glob_0_88, - "./Pages/Studio/StudioArchived.jsx": __vite_glob_0_89, - "./Pages/Studio/StudioArtworkAnalytics.jsx": __vite_glob_0_90, - "./Pages/Studio/StudioArtworkEdit.jsx": __vite_glob_0_91, - "./Pages/Studio/StudioArtworks.jsx": __vite_glob_0_92, - "./Pages/Studio/StudioAssets.jsx": __vite_glob_0_93, - "./Pages/Studio/StudioCalendar.jsx": __vite_glob_0_94, - "./Pages/Studio/StudioCardAnalytics.jsx": __vite_glob_0_95, - "./Pages/Studio/StudioCardEditor.jsx": __vite_glob_0_96, - "./Pages/Studio/StudioCardsIndex.jsx": __vite_glob_0_97, - "./Pages/Studio/StudioChallenges.jsx": __vite_glob_0_98, - "./Pages/Studio/StudioCollections.jsx": __vite_glob_0_99, - "./Pages/Studio/StudioComments.jsx": __vite_glob_0_100, - "./Pages/Studio/StudioContentIndex.jsx": __vite_glob_0_101, - "./Pages/Studio/StudioDashboard.jsx": __vite_glob_0_102, - "./Pages/Studio/StudioDrafts.jsx": __vite_glob_0_103, - "./Pages/Studio/StudioFeatured.jsx": __vite_glob_0_104, - "./Pages/Studio/StudioFollowers.jsx": __vite_glob_0_105, - "./Pages/Studio/StudioGroupActivity.jsx": __vite_glob_0_106, - "./Pages/Studio/StudioGroupArtworks.jsx": __vite_glob_0_107, - "./Pages/Studio/StudioGroupAssets.jsx": __vite_glob_0_108, - "./Pages/Studio/StudioGroupChallengeEditor.jsx": __vite_glob_0_109, - "./Pages/Studio/StudioGroupChallenges.jsx": __vite_glob_0_110, - "./Pages/Studio/StudioGroupCollections.jsx": __vite_glob_0_111, - "./Pages/Studio/StudioGroupCreate.jsx": __vite_glob_0_112, - "./Pages/Studio/StudioGroupDashboard.jsx": __vite_glob_0_113, - "./Pages/Studio/StudioGroupEventEditor.jsx": __vite_glob_0_114, - "./Pages/Studio/StudioGroupEvents.jsx": __vite_glob_0_115, - "./Pages/Studio/StudioGroupInvitations.jsx": __vite_glob_0_116, - "./Pages/Studio/StudioGroupJoinRequests.jsx": __vite_glob_0_117, - "./Pages/Studio/StudioGroupMembers.jsx": __vite_glob_0_118, - "./Pages/Studio/StudioGroupPostEditor.jsx": __vite_glob_0_119, - "./Pages/Studio/StudioGroupPosts.jsx": __vite_glob_0_120, - "./Pages/Studio/StudioGroupProjectEditor.jsx": __vite_glob_0_121, - "./Pages/Studio/StudioGroupProjects.jsx": __vite_glob_0_122, - "./Pages/Studio/StudioGroupRecruitment.jsx": __vite_glob_0_123, - "./Pages/Studio/StudioGroupReleaseEditor.jsx": __vite_glob_0_124, - "./Pages/Studio/StudioGroupReleases.jsx": __vite_glob_0_125, - "./Pages/Studio/StudioGroupReputation.jsx": __vite_glob_0_126, - "./Pages/Studio/StudioGroupReviewQueue.jsx": __vite_glob_0_127, - "./Pages/Studio/StudioGroupSettings.jsx": __vite_glob_0_128, - "./Pages/Studio/StudioGroupsIndex.jsx": __vite_glob_0_129, - "./Pages/Studio/StudioGrowth.jsx": __vite_glob_0_130, - "./Pages/Studio/StudioInbox.jsx": __vite_glob_0_131, - "./Pages/Studio/StudioNewsEditor.jsx": __vite_glob_0_132, - "./Pages/Studio/StudioNewsIndex.jsx": __vite_glob_0_133, - "./Pages/Studio/StudioNewsTaxonomies.jsx": __vite_glob_0_134, - "./Pages/Studio/StudioPreferences.jsx": __vite_glob_0_135, - "./Pages/Studio/StudioProfile.jsx": __vite_glob_0_136, - "./Pages/Studio/StudioScheduled.jsx": __vite_glob_0_137, - "./Pages/Studio/StudioSearch.jsx": __vite_glob_0_138, - "./Pages/Studio/StudioSettings.jsx": __vite_glob_0_139, - "./Pages/Studio/StudioStories.jsx": __vite_glob_0_140, - "./Pages/Studio/StudioUploadQueue.jsx": __vite_glob_0_141, - "./Pages/Studio/StudioWorldEditor.jsx": __vite_glob_0_142, - "./Pages/Studio/StudioWorldsIndex.jsx": __vite_glob_0_143, - "./Pages/Upload/Index.jsx": __vite_glob_0_144, - "./Pages/World/WorldIndex.jsx": __vite_glob_0_145, - "./Pages/World/WorldShow.jsx": __vite_glob_0_146 + "./Pages/Academy/Billing/Account.jsx": __vite_glob_0_0, + "./Pages/Academy/Billing/Cancel.jsx": __vite_glob_0_1, + "./Pages/Academy/Billing/Pricing.jsx": __vite_glob_0_2, + "./Pages/Academy/Billing/Success.jsx": __vite_glob_0_3, + "./Pages/Academy/ChallengeSubmit.jsx": __vite_glob_0_4, + "./Pages/Academy/CoursesIndex.jsx": __vite_glob_0_5, + "./Pages/Academy/CoursesShow.jsx": __vite_glob_0_6, + "./Pages/Academy/Index.jsx": __vite_glob_0_7, + "./Pages/Academy/List.jsx": __vite_glob_0_8, + "./Pages/Academy/Show.jsx": __vite_glob_0_9, + "./Pages/Admin/Academy/AnalyticsContent.jsx": __vite_glob_0_10, + "./Pages/Admin/Academy/AnalyticsFunnel.jsx": __vite_glob_0_11, + "./Pages/Admin/Academy/AnalyticsIntelligence.jsx": __vite_glob_0_12, + "./Pages/Admin/Academy/AnalyticsNav.jsx": __vite_glob_0_13, + "./Pages/Admin/Academy/AnalyticsOverview.jsx": __vite_glob_0_14, + "./Pages/Admin/Academy/AnalyticsSearch.jsx": __vite_glob_0_15, + "./Pages/Admin/Academy/Billing.jsx": __vite_glob_0_16, + "./Pages/Admin/Academy/CourseBuilder.jsx": __vite_glob_0_17, + "./Pages/Admin/Academy/CourseEditor.jsx": __vite_glob_0_18, + "./Pages/Admin/Academy/CrudForm.jsx": __vite_glob_0_19, + "./Pages/Admin/Academy/CrudIndex.jsx": __vite_glob_0_20, + "./Pages/Admin/Academy/Dashboard.jsx": __vite_glob_0_21, + "./Pages/Admin/Academy/LessonEditor.jsx": __vite_glob_0_22, + "./Pages/Admin/Academy/Submissions.jsx": __vite_glob_0_23, + "./Pages/Admin/AiBiography.jsx": __vite_glob_0_24, + "./Pages/Admin/Artworks.jsx": __vite_glob_0_25, + "./Pages/Admin/AuthAudit.jsx": __vite_glob_0_26, + "./Pages/Admin/DailyActivity.jsx": __vite_glob_0_27, + "./Pages/Admin/Dashboard.jsx": __vite_glob_0_28, + "./Pages/Admin/FeaturedArtworks.jsx": __vite_glob_0_29, + "./Pages/Admin/HomepageAnnouncements/Form.jsx": __vite_glob_0_30, + "./Pages/Admin/HomepageAnnouncements/Index.jsx": __vite_glob_0_31, + "./Pages/Admin/Settings.jsx": __vite_glob_0_32, + "./Pages/Admin/Stories.jsx": __vite_glob_0_33, + "./Pages/Admin/UploadQueue.jsx": __vite_glob_0_34, + "./Pages/Admin/UsernameQueue.jsx": __vite_glob_0_35, + "./Pages/Admin/Users/Index.jsx": __vite_glob_0_36, + "./Pages/Artwork/SimilarArtworksHeader.jsx": __vite_glob_0_37, + "./Pages/ArtworkPage.jsx": __vite_glob_0_38, + "./Pages/CategoriesPage.jsx": __vite_glob_0_39, + "./Pages/Collection/CollectionAnalytics.jsx": __vite_glob_0_40, + "./Pages/Collection/CollectionDashboard.jsx": __vite_glob_0_41, + "./Pages/Collection/CollectionFeaturedIndex.jsx": __vite_glob_0_42, + "./Pages/Collection/CollectionHistory.jsx": __vite_glob_0_43, + "./Pages/Collection/CollectionManage.jsx": __vite_glob_0_44, + "./Pages/Collection/CollectionSeriesShow.jsx": __vite_glob_0_45, + "./Pages/Collection/CollectionShow.jsx": __vite_glob_0_46, + "./Pages/Collection/CollectionStaffProgramming.jsx": __vite_glob_0_47, + "./Pages/Collection/CollectionStaffSurfaces.jsx": __vite_glob_0_48, + "./Pages/Collection/FeaturedArtworksAdmin.jsx": __vite_glob_0_49, + "./Pages/Collection/NovaCardsAdminIndex.jsx": __vite_glob_0_50, + "./Pages/Collection/NovaCardsAssetPackAdmin.jsx": __vite_glob_0_51, + "./Pages/Collection/NovaCardsChallengeAdmin.jsx": __vite_glob_0_52, + "./Pages/Collection/NovaCardsCollectionAdmin.jsx": __vite_glob_0_53, + "./Pages/Collection/NovaCardsTemplateAdmin.jsx": __vite_glob_0_54, + "./Pages/Collection/SavedCollections.jsx": __vite_glob_0_55, + "./Pages/Community/CommunityActivityPage.jsx": __vite_glob_0_56, + "./Pages/Community/LatestCommentsPage.jsx": __vite_glob_0_57, + "./Pages/Feed/FollowingFeed.jsx": __vite_glob_0_58, + "./Pages/Feed/HashtagFeed.jsx": __vite_glob_0_59, + "./Pages/Feed/SavedFeed.jsx": __vite_glob_0_60, + "./Pages/Feed/SearchFeed.jsx": __vite_glob_0_61, + "./Pages/Feed/TrendingFeed.jsx": __vite_glob_0_62, + "./Pages/Forum/ForumCategory.jsx": __vite_glob_0_63, + "./Pages/Forum/ForumEditPost.jsx": __vite_glob_0_64, + "./Pages/Forum/ForumIndex.jsx": __vite_glob_0_65, + "./Pages/Forum/ForumNewThread.jsx": __vite_glob_0_66, + "./Pages/Forum/ForumSection.jsx": __vite_glob_0_67, + "./Pages/Forum/ForumThread.jsx": __vite_glob_0_68, + "./Pages/Group/GroupChallengeShow.jsx": __vite_glob_0_69, + "./Pages/Group/GroupEventShow.jsx": __vite_glob_0_70, + "./Pages/Group/GroupFaqPage.jsx": __vite_glob_0_71, + "./Pages/Group/GroupHelpPage.jsx": __vite_glob_0_72, + "./Pages/Group/GroupIndex.jsx": __vite_glob_0_73, + "./Pages/Group/GroupPostShow.jsx": __vite_glob_0_74, + "./Pages/Group/GroupProjectShow.jsx": __vite_glob_0_75, + "./Pages/Group/GroupQuickstartPage.jsx": __vite_glob_0_76, + "./Pages/Group/GroupReleaseShow.jsx": __vite_glob_0_77, + "./Pages/Group/GroupShow.jsx": __vite_glob_0_78, + "./Pages/Help/AccountHelpPage.jsx": __vite_glob_0_79, + "./Pages/Help/AuthHelpPage.jsx": __vite_glob_0_80, + "./Pages/Help/CardsHelpPage.jsx": __vite_glob_0_81, + "./Pages/Help/HelpCenterPage.jsx": __vite_glob_0_82, + "./Pages/Help/ProfileHelpPage.jsx": __vite_glob_0_83, + "./Pages/Help/StudioHelpPage.jsx": __vite_glob_0_84, + "./Pages/Help/TroubleshootingHelpPage.jsx": __vite_glob_0_85, + "./Pages/Help/UploadHelpPage.jsx": __vite_glob_0_86, + "./Pages/Help/WorldsHelpPage.jsx": __vite_glob_0_87, + "./Pages/Leaderboard/LeaderboardPage.jsx": __vite_glob_0_88, + "./Pages/Messages/Index.jsx": __vite_glob_0_89, + "./Pages/Moderation/AiBiographyAdmin.jsx": __vite_glob_0_90, + "./Pages/Moderation/ArtworkMaturityQueue.jsx": __vite_glob_0_91, + "./Pages/Moderation/WorldWebStoriesIndex.jsx": __vite_glob_0_92, + "./Pages/Moderation/WorldWebStoryEditor.jsx": __vite_glob_0_93, + "./Pages/News/NewsComments.jsx": __vite_glob_0_94, + "./Pages/News/NewsImagePreview.jsx": __vite_glob_0_95, + "./Pages/Profile/ProfileGallery.jsx": __vite_glob_0_96, + "./Pages/Profile/ProfileShow.jsx": __vite_glob_0_97, + "./Pages/Settings/ProfileEdit.jsx": __vite_glob_0_98, + "./Pages/Studio/StudioActivity.jsx": __vite_glob_0_99, + "./Pages/Studio/StudioAnalytics.jsx": __vite_glob_0_100, + "./Pages/Studio/StudioArchived.jsx": __vite_glob_0_101, + "./Pages/Studio/StudioArtworkAnalytics.jsx": __vite_glob_0_102, + "./Pages/Studio/StudioArtworkEdit.jsx": __vite_glob_0_103, + "./Pages/Studio/StudioArtworks.jsx": __vite_glob_0_104, + "./Pages/Studio/StudioAssets.jsx": __vite_glob_0_105, + "./Pages/Studio/StudioCalendar.jsx": __vite_glob_0_106, + "./Pages/Studio/StudioCardAnalytics.jsx": __vite_glob_0_107, + "./Pages/Studio/StudioCardEditor.jsx": __vite_glob_0_108, + "./Pages/Studio/StudioCardsIndex.jsx": __vite_glob_0_109, + "./Pages/Studio/StudioChallenges.jsx": __vite_glob_0_110, + "./Pages/Studio/StudioCollections.jsx": __vite_glob_0_111, + "./Pages/Studio/StudioComments.jsx": __vite_glob_0_112, + "./Pages/Studio/StudioContentIndex.jsx": __vite_glob_0_113, + "./Pages/Studio/StudioDashboard.jsx": __vite_glob_0_114, + "./Pages/Studio/StudioDrafts.jsx": __vite_glob_0_115, + "./Pages/Studio/StudioFeatured.jsx": __vite_glob_0_116, + "./Pages/Studio/StudioFollowers.jsx": __vite_glob_0_117, + "./Pages/Studio/StudioGroupActivity.jsx": __vite_glob_0_118, + "./Pages/Studio/StudioGroupArtworks.jsx": __vite_glob_0_119, + "./Pages/Studio/StudioGroupAssets.jsx": __vite_glob_0_120, + "./Pages/Studio/StudioGroupChallengeEditor.jsx": __vite_glob_0_121, + "./Pages/Studio/StudioGroupChallenges.jsx": __vite_glob_0_122, + "./Pages/Studio/StudioGroupCollections.jsx": __vite_glob_0_123, + "./Pages/Studio/StudioGroupCreate.jsx": __vite_glob_0_124, + "./Pages/Studio/StudioGroupDashboard.jsx": __vite_glob_0_125, + "./Pages/Studio/StudioGroupEventEditor.jsx": __vite_glob_0_126, + "./Pages/Studio/StudioGroupEvents.jsx": __vite_glob_0_127, + "./Pages/Studio/StudioGroupInvitations.jsx": __vite_glob_0_128, + "./Pages/Studio/StudioGroupJoinRequests.jsx": __vite_glob_0_129, + "./Pages/Studio/StudioGroupMembers.jsx": __vite_glob_0_130, + "./Pages/Studio/StudioGroupPostEditor.jsx": __vite_glob_0_131, + "./Pages/Studio/StudioGroupPosts.jsx": __vite_glob_0_132, + "./Pages/Studio/StudioGroupProjectEditor.jsx": __vite_glob_0_133, + "./Pages/Studio/StudioGroupProjects.jsx": __vite_glob_0_134, + "./Pages/Studio/StudioGroupRecruitment.jsx": __vite_glob_0_135, + "./Pages/Studio/StudioGroupReleaseEditor.jsx": __vite_glob_0_136, + "./Pages/Studio/StudioGroupReleases.jsx": __vite_glob_0_137, + "./Pages/Studio/StudioGroupReputation.jsx": __vite_glob_0_138, + "./Pages/Studio/StudioGroupReviewQueue.jsx": __vite_glob_0_139, + "./Pages/Studio/StudioGroupSettings.jsx": __vite_glob_0_140, + "./Pages/Studio/StudioGroupsIndex.jsx": __vite_glob_0_141, + "./Pages/Studio/StudioGrowth.jsx": __vite_glob_0_142, + "./Pages/Studio/StudioInbox.jsx": __vite_glob_0_143, + "./Pages/Studio/StudioNewsEditor.jsx": __vite_glob_0_144, + "./Pages/Studio/StudioNewsIndex.jsx": __vite_glob_0_145, + "./Pages/Studio/StudioNewsTaxonomies.jsx": __vite_glob_0_146, + "./Pages/Studio/StudioPreferences.jsx": __vite_glob_0_147, + "./Pages/Studio/StudioProfile.jsx": __vite_glob_0_148, + "./Pages/Studio/StudioScheduled.jsx": __vite_glob_0_149, + "./Pages/Studio/StudioSearch.jsx": __vite_glob_0_150, + "./Pages/Studio/StudioSettings.jsx": __vite_glob_0_151, + "./Pages/Studio/StudioStories.jsx": __vite_glob_0_152, + "./Pages/Studio/StudioUploadQueue.jsx": __vite_glob_0_153, + "./Pages/Studio/StudioWorldEditor.jsx": __vite_glob_0_154, + "./Pages/Studio/StudioWorldsIndex.jsx": __vite_glob_0_155, + "./Pages/Upload/Index.jsx": __vite_glob_0_156, + "./Pages/World/WorldIndex.jsx": __vite_glob_0_157, + "./Pages/World/WorldShow.jsx": __vite_glob_0_158 }); const ClientOnlyPlaceholder = () => null; d( diff --git a/composer.json b/composer.json index 9d8b844b..ddbef3b0 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "inertiajs/inertia-laravel": "^1.0", "intervention/image": "^3.11", "jenssegers/agent": "*", + "laravel/cashier": "^16.5", "laravel/framework": "^12.0", "laravel/horizon": "^5.45", "laravel/reverb": "^1.0", @@ -113,6 +114,10 @@ "composer/installers": true, "pestphp/pest-plugin": true, "php-http/discovery": true + }, + "platform": { + "ext-pcntl": "8.4.0", + "ext-posix": "8.4.0" } }, "minimum-stability": "stable", diff --git a/composer.lock b/composer.lock index 01a1a132..730ea70a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,27 +4,27 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b69bd9ae71e91b0cae4ae1a4f7020cbc", + "content-hash": "ae4cbbbd3390e2a18df6cb08a6caf6aa", "packages": [ { "name": "alexusmai/laravel-file-manager", - "version": "3.3.2", + "version": "3.3.3", "source": { "type": "git", "url": "https://github.com/alexusmai/laravel-file-manager.git", - "reference": "58ed1930c50c17ca01b24f82131378f0bd1d1a03" + "reference": "74bebe32d821d19c1c026545af7e4043fe074aba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/alexusmai/laravel-file-manager/zipball/58ed1930c50c17ca01b24f82131378f0bd1d1a03", - "reference": "58ed1930c50c17ca01b24f82131378f0bd1d1a03", + "url": "https://api.github.com/repos/alexusmai/laravel-file-manager/zipball/74bebe32d821d19c1c026545af7e4043fe074aba", + "reference": "74bebe32d821d19c1c026545af7e4043fe074aba", "shasum": "" }, "require": { "ext-json": "*", "ext-zip": "*", "intervention/image-laravel": "^1.2.0", - "laravel/framework": "^9.0|^10.0|^11.0|^12.0", + "laravel/framework": "^9.0|^10.0|^11.0|^12.0|^13.0", "league/flysystem": "^3.0", "php": "^8.1" }, @@ -61,9 +61,9 @@ ], "support": { "issues": "https://github.com/alexusmai/laravel-file-manager/issues", - "source": "https://github.com/alexusmai/laravel-file-manager/tree/3.3.2" + "source": "https://github.com/alexusmai/laravel-file-manager/tree/3.3.3" }, - "time": "2025-12-09T11:45:27+00:00" + "time": "2026-05-12T10:06:23+00:00" }, { "name": "aws/aws-crt-php", @@ -121,16 +121,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.380.2", + "version": "3.381.3", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "af23f62b555be3ab337de571b45ae28558b6daf6" + "reference": "989f4776aed2a3b184a5b64046542e8fe66e99e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/af23f62b555be3ab337de571b45ae28558b6daf6", - "reference": "af23f62b555be3ab337de571b45ae28558b6daf6", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/989f4776aed2a3b184a5b64046542e8fe66e99e2", + "reference": "989f4776aed2a3b184a5b64046542e8fe66e99e2", "shasum": "" }, "require": { @@ -212,9 +212,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.380.2" + "source": "https://github.com/aws/aws-sdk-php/tree/3.381.3" }, - "time": "2026-05-06T18:28:56+00:00" + "time": "2026-05-18T18:21:09+00:00" }, { "name": "brick/math", @@ -2007,16 +2007,16 @@ }, { "name": "jaybizzle/crawler-detect", - "version": "v1.3.9", + "version": "v1.3.11", "source": { "type": "git", "url": "https://github.com/JayBizzle/Crawler-Detect.git", - "reference": "5edf2e43d9f42e5baa6f844826213257c247b309" + "reference": "484792759de89fe94ea6a192065ea7cd99f1eaa2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/JayBizzle/Crawler-Detect/zipball/5edf2e43d9f42e5baa6f844826213257c247b309", - "reference": "5edf2e43d9f42e5baa6f844826213257c247b309", + "url": "https://api.github.com/repos/JayBizzle/Crawler-Detect/zipball/484792759de89fe94ea6a192065ea7cd99f1eaa2", + "reference": "484792759de89fe94ea6a192065ea7cd99f1eaa2", "shasum": "" }, "require": { @@ -2053,9 +2053,9 @@ ], "support": { "issues": "https://github.com/JayBizzle/Crawler-Detect/issues", - "source": "https://github.com/JayBizzle/Crawler-Detect/tree/v1.3.9" + "source": "https://github.com/JayBizzle/Crawler-Detect/tree/v1.3.11" }, - "time": "2026-04-14T19:32:41+00:00" + "time": "2026-05-10T14:08:06+00:00" }, { "name": "jean85/pretty-package-versions", @@ -2201,17 +2201,106 @@ "time": "2020-06-13T08:05:20+00:00" }, { - "name": "laravel/framework", - "version": "v12.58.0", + "name": "laravel/cashier", + "version": "v16.5.3", "source": { "type": "git", - "url": "https://github.com/laravel/framework.git", - "reference": "6172ae1f44ba5d89e111057ee4a4e7c27f5a610d" + "url": "https://github.com/laravel/cashier-stripe.git", + "reference": "b6bcd6b4d79acead34d00a5a528c904d67c5e08a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/6172ae1f44ba5d89e111057ee4a4e7c27f5a610d", - "reference": "6172ae1f44ba5d89e111057ee4a4e7c27f5a610d", + "url": "https://api.github.com/repos/laravel/cashier-stripe/zipball/b6bcd6b4d79acead34d00a5a528c904d67c5e08a", + "reference": "b6bcd6b4d79acead34d00a5a528c904d67c5e08a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^10.0|^11.0|^12.0|^13.0", + "illuminate/log": "^10.0|^11.0|^12.0|^13.0", + "illuminate/notifications": "^10.0|^11.0|^12.0|^13.0", + "illuminate/pagination": "^10.0|^11.0|^12.0|^13.0", + "illuminate/routing": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/view": "^10.0|^11.0|^12.0|^13.0", + "moneyphp/money": "^4.0", + "nesbot/carbon": "^2.0|^3.0", + "php": "^8.1", + "stripe/stripe-php": "^17.3.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-kernel": "^6.0|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.22.1", + "symfony/polyfill-php84": "^1.32" + }, + "require-dev": { + "dompdf/dompdf": "^2.0|^3.0", + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10", + "spatie/laravel-ray": "^1.40" + }, + "suggest": { + "dompdf/dompdf": "Required when generating and downloading invoice PDF's using Dompdf (^2.0|^3.0).", + "ext-intl": "Allows for more locales besides the default \"en\" when formatting money values.", + "spatie/laravel-pdf": "Required when generating and downloading invoice PDF's using Cashier's LaravelPdfInvoiceRenderer." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Cashier\\CashierServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "16.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Cashier\\": "src/", + "Laravel\\Cashier\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Dries Vints", + "email": "dries@laravel.com" + } + ], + "description": "Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.", + "keywords": [ + "billing", + "laravel", + "stripe" + ], + "support": { + "issues": "https://github.com/laravel/cashier/issues", + "source": "https://github.com/laravel/cashier" + }, + "time": "2026-05-05T21:18:35+00:00" + }, + { + "name": "laravel/framework", + "version": "v12.59.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "70a838b1a6f12abaf5bebb76ea4f28fbd37451fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/70a838b1a6f12abaf5bebb76ea4f28fbd37451fa", + "reference": "70a838b1a6f12abaf5bebb76ea4f28fbd37451fa", "shasum": "" }, "require": { @@ -2420,7 +2509,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-04-26T16:42:04+00:00" + "time": "2026-05-14T15:31:40+00:00" }, { "name": "laravel/horizon", @@ -2504,16 +2593,16 @@ }, { "name": "laravel/prompts", - "version": "v0.3.17", + "version": "v0.3.18", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "6a82ac19a28b916ae0885828795dbd4c59d9a818" + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/6a82ac19a28b916ae0885828795dbd4c59d9a818", - "reference": "6a82ac19a28b916ae0885828795dbd4c59d9a818", + "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72", "shasum": "" }, "require": { @@ -2557,22 +2646,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.17" + "source": "https://github.com/laravel/prompts/tree/v0.3.18" }, - "time": "2026-04-20T16:07:33+00:00" + "time": "2026-05-19T00:47:18+00:00" }, { "name": "laravel/reverb", - "version": "v1.10.1", + "version": "v1.10.2", "source": { "type": "git", "url": "https://github.com/laravel/reverb.git", - "reference": "a96310ae8b844d4862b2188a3cd6e79434893a6b" + "reference": "43a5c0a99b1aaba33dc32f97fcf51f182dd8c8ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/reverb/zipball/a96310ae8b844d4862b2188a3cd6e79434893a6b", - "reference": "a96310ae8b844d4862b2188a3cd6e79434893a6b", + "url": "https://api.github.com/repos/laravel/reverb/zipball/43a5c0a99b1aaba33dc32f97fcf51f182dd8c8ac", + "reference": "43a5c0a99b1aaba33dc32f97fcf51f182dd8c8ac", "shasum": "" }, "require": { @@ -2636,9 +2725,9 @@ ], "support": { "issues": "https://github.com/laravel/reverb/issues", - "source": "https://github.com/laravel/reverb/tree/v1.10.1" + "source": "https://github.com/laravel/reverb/tree/v1.10.2" }, - "time": "2026-04-30T12:07:26+00:00" + "time": "2026-05-10T15:47:52+00:00" }, { "name": "laravel/scout", @@ -3166,16 +3255,16 @@ }, { "name": "league/flysystem", - "version": "3.33.0", + "version": "3.34.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "570b8871e0ce693764434b29154c54b434905350" + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", - "reference": "570b8871e0ce693764434b29154c54b434905350", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", "shasum": "" }, "require": { @@ -3243,26 +3332,26 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" }, - "time": "2026-03-25T07:59:30+00:00" + "time": "2026-05-14T10:28:08+00:00" }, { "name": "league/flysystem-aws-s3-v3", - "version": "3.32.0", + "version": "3.34.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", - "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0" + "reference": "0c62fdac907791d8649ad3c61cb7a77628344fb8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0", - "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/0c62fdac907791d8649ad3c61cb7a77628344fb8", + "reference": "0c62fdac907791d8649ad3c61cb7a77628344fb8", "shasum": "" }, "require": { - "aws/aws-sdk-php": "^3.295.10", + "aws/aws-sdk-php": "^3.371.5", "league/flysystem": "^3.10.0", "league/mime-type-detection": "^1.0.0", "php": "^8.0.2" @@ -3298,9 +3387,9 @@ "storage" ], "support": { - "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0" + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.34.0" }, - "time": "2026-02-25T16:46:44+00:00" + "time": "2026-05-04T08:24:00+00:00" }, { "name": "league/flysystem-local", @@ -3807,6 +3896,96 @@ ], "time": "2026-04-14T12:32:34+00:00" }, + { + "name": "moneyphp/money", + "version": "v4.9.0", + "source": { + "type": "git", + "url": "https://github.com/moneyphp/money.git", + "reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/moneyphp/money/zipball/d49ee625c6ba79b9d7a228ce153b02fc1032152b", + "reference": "d49ee625c6ba79b9d7a228ce153b02fc1032152b", + "shasum": "" + }, + "require": { + "ext-bcmath": "*", + "ext-filter": "*", + "ext-json": "*", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cache/taggable-cache": "^1.1.0", + "doctrine/coding-standard": "^12.0", + "doctrine/instantiator": "^1.5.0 || ^2.0", + "ext-gmp": "*", + "ext-intl": "*", + "florianv/exchanger": "^2.8.1", + "florianv/swap": "^4.3.0", + "moneyphp/crypto-currencies": "^1.1.0", + "moneyphp/iso-currencies": "^3.4", + "php-http/message": "^1.16.0", + "php-http/mock-client": "^1.6.0", + "phpbench/phpbench": "^1.2.5", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1.9", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.9", + "psr/cache": "^1.0.1 || ^2.0 || ^3.0", + "ticketswap/phpstan-error-formatter": "^1.1" + }, + "suggest": { + "ext-gmp": "Calculate without integer limits", + "ext-intl": "Format Money objects with intl", + "florianv/exchanger": "Exchange rates library for PHP", + "florianv/swap": "Exchange rates library for PHP", + "psr/cache-implementation": "Used for Currency caching" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Money\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Verraes", + "email": "mathias@verraes.net", + "homepage": "http://verraes.net" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + }, + { + "name": "Frederik Bosch", + "email": "f.bosch@genkgo.nl" + } + ], + "description": "PHP implementation of Fowler's Money pattern", + "homepage": "http://moneyphp.org", + "keywords": [ + "Value Object", + "money", + "vo" + ], + "support": { + "issues": "https://github.com/moneyphp/money/issues", + "source": "https://github.com/moneyphp/money/tree/v4.9.0" + }, + "time": "2026-05-04T20:23:15+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -4150,16 +4329,16 @@ }, { "name": "nette/utils", - "version": "v4.1.3", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", "shasum": "" }, "require": { @@ -4235,9 +4414,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.3" + "source": "https://github.com/nette/utils/tree/v4.1.4" }, - "time": "2026-02-13T03:05:33+00:00" + "time": "2026-05-11T20:49:54+00:00" }, { "name": "nikic/php-parser", @@ -4581,102 +4760,6 @@ }, "time": "2020-10-15T08:29:30+00:00" }, - { - "name": "paragonie/sodium_compat", - "version": "v2.5.0", - "source": { - "type": "git", - "url": "https://github.com/paragonie/sodium_compat.git", - "reference": "4714da6efdc782c06690bc72ce34fae7941c2d9f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/4714da6efdc782c06690bc72ce34fae7941c2d9f", - "reference": "4714da6efdc782c06690bc72ce34fae7941c2d9f", - "shasum": "" - }, - "require": { - "php": "^8.1", - "php-64bit": "*" - }, - "require-dev": { - "infection/infection": "^0", - "nikic/php-fuzzer": "^0", - "phpunit/phpunit": "^7|^8|^9|^10|^11", - "vimeo/psalm": "^4|^5|^6" - }, - "suggest": { - "ext-sodium": "Better performance, password hashing (Argon2i), secure memory management (memzero), and better security." - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "files": [ - "autoload.php" - ], - "psr-4": { - "ParagonIE\\Sodium\\": "namespaced/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "ISC" - ], - "authors": [ - { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com" - }, - { - "name": "Frank Denis", - "email": "jedisct1@pureftpd.org" - } - ], - "description": "Pure PHP implementation of libsodium; uses the PHP extension if it exists", - "keywords": [ - "Authentication", - "BLAKE2b", - "ChaCha20", - "ChaCha20-Poly1305", - "Chapoly", - "Curve25519", - "Ed25519", - "EdDSA", - "Edwards-curve Digital Signature Algorithm", - "Elliptic Curve Diffie-Hellman", - "Poly1305", - "Pure-PHP cryptography", - "RFC 7748", - "RFC 8032", - "Salpoly", - "Salsa20", - "X25519", - "XChaCha20-Poly1305", - "XSalsa20-Poly1305", - "Xchacha20", - "Xsalsa20", - "aead", - "cryptography", - "ecdh", - "elliptic curve", - "elliptic curve cryptography", - "encryption", - "libsodium", - "php", - "public-key cryptography", - "secret-key cryptography", - "side-channel resistant" - ], - "support": { - "issues": "https://github.com/paragonie/sodium_compat/issues", - "source": "https://github.com/paragonie/sodium_compat/tree/v2.5.0" - }, - "time": "2025-12-30T16:12:18+00:00" - }, { "name": "php-http/discovery", "version": "1.20.0", @@ -5497,23 +5580,22 @@ }, { "name": "pusher/pusher-php-server", - "version": "7.2.7", + "version": "7.2.8", "source": { "type": "git", "url": "https://github.com/pusher/pusher-http-php.git", - "reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7" + "reference": "4aa139ed2a2a805cd265449b691198beee1309d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/148b0b5100d000ed57195acdf548a2b1b38ee3f7", - "reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7", + "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/4aa139ed2a2a805cd265449b691198beee1309d2", + "reference": "4aa139ed2a2a805cd265449b691198beee1309d2", "shasum": "" }, "require": { "ext-curl": "*", "ext-json": "*", "guzzlehttp/guzzle": "^7.2", - "paragonie/sodium_compat": "^1.6|^2.0", "php": "^7.3|^8.0", "psr/log": "^1.0|^2.0|^3.0" }, @@ -5552,9 +5634,9 @@ ], "support": { "issues": "https://github.com/pusher/pusher-http-php/issues", - "source": "https://github.com/pusher/pusher-http-php/tree/7.2.7" + "source": "https://github.com/pusher/pusher-http-php/tree/7.2.8" }, - "time": "2025-01-06T10:56:20+00:00" + "time": "2026-05-18T13:11:36+00:00" }, { "name": "ralouphie/getallheaders", @@ -6653,6 +6735,65 @@ }, "time": "2026-03-18T22:13:24+00:00" }, + { + "name": "stripe/stripe-php", + "version": "v17.6.0", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/a6219df5df1324a0d3f1da25fb5e4b8a3307ea16", + "reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=5.6.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.72.0", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^5.7 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Stripe\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stripe and contributors", + "homepage": "https://github.com/stripe/stripe-php/contributors" + } + ], + "description": "Stripe PHP Library", + "homepage": "https://stripe.com/", + "keywords": [ + "api", + "payment processing", + "stripe" + ], + "support": { + "issues": "https://github.com/stripe/stripe-php/issues", + "source": "https://github.com/stripe/stripe-php/tree/v17.6.0" + }, + "time": "2025-08-27T19:32:42+00:00" + }, { "name": "symfony/clock", "version": "v8.0.8", @@ -6732,16 +6873,16 @@ }, { "name": "symfony/console", - "version": "v7.4.9", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "d7d2b64a45a89d607865927b176fa51c33ddbb58" + "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/d7d2b64a45a89d607865927b176fa51c33ddbb58", - "reference": "d7d2b64a45a89d607865927b176fa51c33ddbb58", + "url": "https://api.github.com/repos/symfony/console/zipball/ed0107e43ab452aa77ae99e005b95e56b556e075", + "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075", "shasum": "" }, "require": { @@ -6806,7 +6947,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.9" + "source": "https://github.com/symfony/console/tree/v7.4.11" }, "funding": [ { @@ -6826,7 +6967,7 @@ "type": "tidelift" } ], - "time": "2026-04-22T15:21:55+00:00" + "time": "2026-05-13T12:04:42+00:00" }, { "name": "symfony/css-selector", @@ -7217,16 +7358,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.9", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d1ec4543d5c6c2dac78503c2fae5ea0b3608ce40" + "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d1ec4543d5c6c2dac78503c2fae5ea0b3608ce40", - "reference": "d1ec4543d5c6c2dac78503c2fae5ea0b3608ce40", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/224db910898ce1317b892a9a1338f1f8f17eb7c7", + "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7", "shasum": "" }, "require": { @@ -7263,7 +7404,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.9" + "source": "https://github.com/symfony/filesystem/tree/v8.0.11" }, "funding": [ { @@ -7283,7 +7424,7 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:51:42+00:00" + "time": "2026-05-11T16:39:47+00:00" }, { "name": "symfony/finder", @@ -7437,16 +7578,16 @@ }, { "name": "symfony/http-kernel", - "version": "v7.4.10", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "23486f59234c6fd6e8f1bec97124f3829d686627" + "reference": "eb9d68199af3fcfb3fb4d2e227367b68f8c1bb88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/23486f59234c6fd6e8f1bec97124f3829d686627", - "reference": "23486f59234c6fd6e8f1bec97124f3829d686627", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/eb9d68199af3fcfb3fb4d2e227367b68f8c1bb88", + "reference": "eb9d68199af3fcfb3fb4d2e227367b68f8c1bb88", "shasum": "" }, "require": { @@ -7532,7 +7673,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.10" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.11" }, "funding": [ { @@ -7552,7 +7693,7 @@ "type": "tidelift" } ], - "time": "2026-05-06T12:07:34+00:00" + "time": "2026-05-13T17:55:00+00:00" }, { "name": "symfony/mailer", @@ -7963,6 +8104,94 @@ ], "time": "2026-04-26T13:13:48+00:00" }, + { + "name": "symfony/polyfill-intl-icu", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-icu.git", + "reference": "3510b63d07376b04e57e27e82607d468bb134f78" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/3510b63d07376b04e57e27e82607d468bb134f78", + "reference": "3510b63d07376b04e57e27e82607d468bb134f78", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance and support of other locales than \"en\"" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Icu\\": "" + }, + "classmap": [ + "Resources/stubs" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's ICU-related data and classes", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "icu", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:50:15+00:00" + }, { "name": "symfony/polyfill-intl-idn", "version": "v1.37.0", @@ -8709,16 +8938,16 @@ }, { "name": "symfony/process", - "version": "v7.4.8", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "60f19cd3badc8de688421e21e4305eba50f8089a" + "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a", - "reference": "60f19cd3badc8de688421e21e4305eba50f8089a", + "url": "https://api.github.com/repos/symfony/process/zipball/d9593c9efa40499eb078b81144de42cbc28a31f0", + "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0", "shasum": "" }, "require": { @@ -8750,7 +8979,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.8" + "source": "https://github.com/symfony/process/tree/v7.4.11" }, "funding": [ { @@ -8770,7 +8999,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-11T16:55:21+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -9033,16 +9262,16 @@ }, { "name": "symfony/string", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", - "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", + "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff", + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff", "shasum": "" }, "require": { @@ -9099,7 +9328,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.8" + "source": "https://github.com/symfony/string/tree/v8.0.11" }, "funding": [ { @@ -9119,7 +9348,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/translation", @@ -9676,16 +9905,16 @@ }, { "name": "yajra/laravel-datatables-oracle", - "version": "v12.7.0", + "version": "v12.7.2", "source": { "type": "git", "url": "https://github.com/yajra/laravel-datatables.git", - "reference": "1e4251feeb21f17a817ae06ed29ec91338641868" + "reference": "af48ca6c9f05ee4b67353c630a8f2cae0f2a8a46" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/yajra/laravel-datatables/zipball/1e4251feeb21f17a817ae06ed29ec91338641868", - "reference": "1e4251feeb21f17a817ae06ed29ec91338641868", + "url": "https://api.github.com/repos/yajra/laravel-datatables/zipball/af48ca6c9f05ee4b67353c630a8f2cae0f2a8a46", + "reference": "af48ca6c9f05ee4b67353c630a8f2cae0f2a8a46", "shasum": "" }, "require": { @@ -9753,7 +9982,7 @@ ], "support": { "issues": "https://github.com/yajra/laravel-datatables/issues", - "source": "https://github.com/yajra/laravel-datatables/tree/v12.7.0" + "source": "https://github.com/yajra/laravel-datatables/tree/v12.7.2" }, "funding": [ { @@ -9761,7 +9990,7 @@ "type": "github" } ], - "time": "2026-02-20T15:00:37+00:00" + "time": "2026-05-16T02:08:01+00:00" } ], "packages-dev": [ @@ -10401,16 +10630,16 @@ }, { "name": "laravel/boost", - "version": "v2.4.6", + "version": "v2.4.7", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "c9ea6368c66f7c0e6a9b26706b401de900cdb9ac" + "reference": "cebd69eb8ebcefd27653ba95407ef8be965ec239" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/c9ea6368c66f7c0e6a9b26706b401de900cdb9ac", - "reference": "c9ea6368c66f7c0e6a9b26706b401de900cdb9ac", + "url": "https://api.github.com/repos/laravel/boost/zipball/cebd69eb8ebcefd27653ba95407ef8be965ec239", + "reference": "cebd69eb8ebcefd27653ba95407ef8be965ec239", "shasum": "" }, "require": { @@ -10463,7 +10692,7 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-04-28T11:52:01+00:00" + "time": "2026-05-18T12:23:29+00:00" }, { "name": "laravel/breeze", @@ -10810,16 +11039,16 @@ }, { "name": "laravel/sail", - "version": "v1.58.0", + "version": "v1.59.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "2e5e968138ca52ed87d712449697a8364d73b466" + "reference": "a41abad557e487eaefde6c9873085ed086fdf47a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/2e5e968138ca52ed87d712449697a8364d73b466", - "reference": "2e5e968138ca52ed87d712449697a8364d73b466", + "url": "https://api.github.com/repos/laravel/sail/zipball/a41abad557e487eaefde6c9873085ed086fdf47a", + "reference": "a41abad557e487eaefde6c9873085ed086fdf47a", "shasum": "" }, "require": { @@ -10869,7 +11098,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-04-27T13:38:34+00:00" + "time": "2026-05-13T14:02:20+00:00" }, { "name": "mockery/mockery", @@ -12521,23 +12750,23 @@ }, { "name": "sebastian/cli-parser", - "version": "4.2.0", + "version": "4.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -12566,7 +12795,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" }, "funding": [ { @@ -12586,7 +12815,7 @@ "type": "tidelift" } ], - "time": "2025-09-14T09:36:45+00:00" + "time": "2026-05-17T05:29:34+00:00" }, { "name": "sebastian/comparator", @@ -13470,16 +13699,16 @@ }, { "name": "symfony/yaml", - "version": "v8.0.10", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "aa9ee60c41d9b20a2468c41ff0a32e2a7405ac05" + "reference": "48046fbd5567bd1717f278eaa2cfc3131f489984" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/aa9ee60c41d9b20a2468c41ff0a32e2a7405ac05", - "reference": "aa9ee60c41d9b20a2468c41ff0a32e2a7405ac05", + "url": "https://api.github.com/repos/symfony/yaml/zipball/48046fbd5567bd1717f278eaa2cfc3131f489984", + "reference": "48046fbd5567bd1717f278eaa2cfc3131f489984", "shasum": "" }, "require": { @@ -13521,7 +13750,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.0.10" + "source": "https://github.com/symfony/yaml/tree/v8.0.11" }, "funding": [ { @@ -13541,7 +13770,7 @@ "type": "tidelift" } ], - "time": "2026-05-05T08:10:04+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", @@ -13724,5 +13953,9 @@ "php": "^8.2" }, "platform-dev": {}, + "platform-overrides": { + "ext-pcntl": "8.4.0", + "ext-posix": "8.4.0" + }, "plugin-api-version": "2.9.0" } diff --git a/config/academy.php b/config/academy.php index 028637f3..f409500f 100644 --- a/config/academy.php +++ b/config/academy.php @@ -2,7 +2,7 @@ return [ 'enabled' => (bool) env('SKINBASE_ACADEMY_ENABLED', true), - 'payments_enabled' => (bool) env('SKINBASE_ACADEMY_PAYMENTS_ENABLED', false), + 'payments_enabled' => (bool) env('SKINBASE_ACADEMY_PAYMENTS_ENABLED', env('ACADEMY_BILLING_ENABLED', false)), 'free_content_enabled' => (bool) env('SKINBASE_ACADEMY_FREE_CONTENT_ENABLED', true), 'challenges_enabled' => (bool) env('SKINBASE_ACADEMY_CHALLENGES_ENABLED', true), 'badges_enabled' => (bool) env('SKINBASE_ACADEMY_BADGES_ENABLED', true), @@ -25,6 +25,7 @@ return [ 'providers' => [ 'ChatGPT', 'Gemini', + 'Adobe Firefly', 'Leonardo', 'Bing', 'Midjourney', diff --git a/config/academy_billing.php b/config/academy_billing.php new file mode 100644 index 00000000..4ebe564c --- /dev/null +++ b/config/academy_billing.php @@ -0,0 +1,28 @@ + (bool) env('ACADEMY_BILLING_ENABLED', false), + + 'subscription_name' => env('ACADEMY_STRIPE_SUBSCRIPTION_NAME', 'academy'), + + 'plans' => [ + 'creator_monthly' => [ + 'label' => 'Creator Monthly', + 'tier' => 'creator', + 'interval' => 'monthly', + 'amount' => '4.99', + 'currency' => 'EUR', + 'stripe_price_id' => env('ACADEMY_CREATOR_MONTHLY_PRICE_ID'), + 'featured' => false, + ], + 'pro_monthly' => [ + 'label' => 'Pro Monthly', + 'tier' => 'pro', + 'interval' => 'monthly', + 'amount' => '9.99', + 'currency' => 'EUR', + 'stripe_price_id' => env('ACADEMY_PRO_MONTHLY_PRICE_ID'), + 'featured' => true, + ], + ], +]; \ No newline at end of file diff --git a/config/cashier.php b/config/cashier.php new file mode 100644 index 00000000..574e2ca6 --- /dev/null +++ b/config/cashier.php @@ -0,0 +1,130 @@ + env('STRIPE_KEY'), + + 'secret' => env('STRIPE_SECRET'), + + /* + |-------------------------------------------------------------------------- + | Cashier Path + |-------------------------------------------------------------------------- + | + | This is the base URI path where Cashier's views, such as the payment + | verification screen, will be available from. You're free to tweak + | this path according to your preferences and application design. + | + */ + + 'path' => env('CASHIER_PATH', 'stripe'), + + /* + |-------------------------------------------------------------------------- + | Stripe Webhooks + |-------------------------------------------------------------------------- + | + | Your Stripe webhook secret is used to prevent unauthorized requests to + | your Stripe webhook handling controllers. The tolerance setting will + | check the drift between the current time and the signed request's. + | + */ + + 'webhook' => [ + 'secret' => env('STRIPE_WEBHOOK_SECRET'), + 'tolerance' => env('STRIPE_WEBHOOK_TOLERANCE', 300), + 'events' => WebhookCommand::DEFAULT_EVENTS, + ], + + /* + |-------------------------------------------------------------------------- + | Currency + |-------------------------------------------------------------------------- + | + | This is the default currency that will be used when generating charges + | from your application. Of course, you are welcome to use any of the + | various world currencies that are currently supported via Stripe. + | + */ + + 'currency' => env('CASHIER_CURRENCY', 'usd'), + + /* + |-------------------------------------------------------------------------- + | Currency Locale + |-------------------------------------------------------------------------- + | + | This is the default locale in which your money values are formatted in + | for display. To utilize other locales besides the default en locale + | verify you have the "intl" PHP extension installed on the system. + | + */ + + 'currency_locale' => env('CASHIER_CURRENCY_LOCALE', 'en'), + + /* + |-------------------------------------------------------------------------- + | Payment Confirmation Notification + |-------------------------------------------------------------------------- + | + | If this setting is enabled, Cashier will automatically notify customers + | whose payments require additional verification. You should listen to + | Stripe's webhooks in order for this feature to function correctly. + | + */ + + 'payment_notification' => env('CASHIER_PAYMENT_NOTIFICATION'), + + /* + |-------------------------------------------------------------------------- + | Invoice Settings + |-------------------------------------------------------------------------- + | + | The following options determine how Cashier invoices are converted from + | HTML into PDFs. You're free to change the options based on the needs + | of your application or your preferences regarding invoice styling. + | + */ + + 'invoices' => [ + // Supported: DompdfInvoiceRenderer::class, LaravelPdfInvoiceRenderer::class + 'renderer' => env('CASHIER_INVOICE_RENDERER', DompdfInvoiceRenderer::class), + + 'options' => [ + // Supported: 'letter', 'legal', 'A4' + 'paper' => env('CASHIER_PAPER', 'letter'), + + 'remote_enabled' => env('CASHIER_REMOTE_ENABLED', false), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Stripe Logger + |-------------------------------------------------------------------------- + | + | This setting defines which logging channel will be used by the Stripe + | library to write log messages. You are free to specify any of your + | logging channels listed inside the "logging" configuration file. + | + */ + + 'logger' => env('CASHIER_LOGGER'), + +]; diff --git a/database/factories/WorldWebStoryFactory.php b/database/factories/WorldWebStoryFactory.php new file mode 100644 index 00000000..74108804 --- /dev/null +++ b/database/factories/WorldWebStoryFactory.php @@ -0,0 +1,67 @@ + + */ +class WorldWebStoryFactory extends Factory +{ + protected $model = WorldWebStory::class; + + public function definition(): array + { + $title = Str::title($this->faker->unique()->words(3, true)); + $slug = Str::slug($title); + + return [ + 'world_id' => World::factory(), + 'slug' => $slug, + 'title' => $title, + 'subtitle' => $this->faker->sentence(6), + 'excerpt' => $this->faker->sentence(14), + 'description' => $this->faker->paragraph(), + 'seo_title' => $title . ' – Skinbase Web Story', + 'seo_description' => $this->faker->sentence(18), + 'poster_portrait_path' => 'web-stories/worlds/' . $slug . '/poster-portrait.webp', + 'poster_square_path' => 'web-stories/worlds/' . $slug . '/poster-square.webp', + 'publisher_logo_path' => 'images/skinbase_logo_96.webp', + 'status' => WorldWebStory::STATUS_DRAFT, + 'featured' => false, + 'active' => true, + 'noindex' => false, + 'published_at' => null, + 'starts_at' => null, + 'ends_at' => null, + 'created_by' => User::factory(), + 'updated_by' => User::factory(), + ]; + } + + public function published(): self + { + return $this->state(fn (): array => [ + 'status' => WorldWebStory::STATUS_PUBLISHED, + 'published_at' => Carbon::now()->subHour(), + ]); + } + + public function visible(): self + { + return $this->published()->state(fn (): array => [ + 'active' => true, + 'noindex' => false, + 'starts_at' => Carbon::now()->subDay(), + 'ends_at' => Carbon::now()->addDay(), + ]); + } +} \ No newline at end of file diff --git a/database/factories/WorldWebStoryPageFactory.php b/database/factories/WorldWebStoryPageFactory.php new file mode 100644 index 00000000..b6bfb672 --- /dev/null +++ b/database/factories/WorldWebStoryPageFactory.php @@ -0,0 +1,50 @@ + + */ +class WorldWebStoryPageFactory extends Factory +{ + protected $model = WorldWebStoryPage::class; + + public function definition(): array + { + return [ + 'story_id' => WorldWebStory::factory(), + 'artwork_id' => null, + 'position' => 1, + 'layout' => WorldWebStoryPage::LAYOUT_ARTWORK, + 'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE, + 'background_path' => 'web-stories/worlds/example/pages/page-01.webp', + 'background_mobile_path' => 'web-stories/worlds/example/pages/page-01.webp', + 'headline' => 'Story headline', + 'body' => 'Short supporting copy for this world web story page.', + 'cta_label' => null, + 'cta_url' => null, + 'alt_text' => 'World story background', + 'caption' => 'Skinbase World', + 'credit_text' => null, + 'text_position' => 'bottom', + 'overlay_strength' => 35, + 'animation' => 'fade-in', + 'active' => true, + ]; + } + + public function withArtwork(): self + { + return $this->state(fn (): array => [ + 'artwork_id' => Artwork::factory(), + 'layout' => WorldWebStoryPage::LAYOUT_ARTWORK, + ]); + } +} \ No newline at end of file diff --git a/database/migrations/2026_05_08_120000_create_world_web_stories_table.php b/database/migrations/2026_05_08_120000_create_world_web_stories_table.php new file mode 100644 index 00000000..10b296b6 --- /dev/null +++ b/database/migrations/2026_05_08_120000_create_world_web_stories_table.php @@ -0,0 +1,49 @@ +id(); + $table->foreignId('world_id')->nullable()->constrained('worlds')->nullOnDelete(); + $table->string('slug')->unique(); + $table->string('title'); + $table->string('subtitle')->nullable(); + $table->text('excerpt')->nullable(); + $table->text('description')->nullable(); + $table->string('seo_title')->nullable(); + $table->text('seo_description')->nullable(); + $table->string('poster_portrait_path')->nullable(); + $table->string('poster_square_path')->nullable(); + $table->string('publisher_logo_path')->nullable(); + $table->enum('status', ['draft', 'published', 'archived'])->default('draft'); + $table->boolean('featured')->default(false); + $table->boolean('active')->default(true); + $table->boolean('noindex')->default(false); + $table->timestamp('published_at')->nullable(); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('world_id'); + $table->index('slug'); + $table->index(['status', 'active', 'published_at']); + $table->index(['featured', 'status', 'active']); + }); + } + + public function down(): void + { + Schema::dropIfExists('world_web_stories'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_05_08_120100_create_world_web_story_pages_table.php b/database/migrations/2026_05_08_120100_create_world_web_story_pages_table.php new file mode 100644 index 00000000..ea249206 --- /dev/null +++ b/database/migrations/2026_05_08_120100_create_world_web_story_pages_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('story_id')->constrained('world_web_stories')->cascadeOnDelete(); + $table->foreignId('artwork_id')->nullable()->constrained('artworks')->nullOnDelete(); + $table->unsignedInteger('position'); + $table->enum('layout', ['cover', 'artwork', 'creator', 'mood', 'collection', 'cta']); + $table->enum('background_type', ['image', 'video', 'gradient']); + $table->string('background_path')->nullable(); + $table->string('background_mobile_path')->nullable(); + $table->string('headline')->nullable(); + $table->text('body')->nullable(); + $table->string('cta_label')->nullable(); + $table->string('cta_url')->nullable(); + $table->text('alt_text')->nullable(); + $table->string('caption')->nullable(); + $table->string('credit_text')->nullable(); + $table->enum('text_position', ['top', 'center', 'bottom'])->default('bottom'); + $table->unsignedTinyInteger('overlay_strength')->default(35); + $table->enum('animation', ['fade-in', 'fly-in-bottom', 'pulse', 'pan-left', 'pan-right'])->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['story_id', 'position']); + $table->index(['story_id', 'active']); + $table->index('artwork_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('world_web_story_pages'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_05_11_000001_create_academy_events_table.php b/database/migrations/2026_05_11_000001_create_academy_events_table.php new file mode 100644 index 00000000..98bdd382 --- /dev/null +++ b/database/migrations/2026_05_11_000001_create_academy_events_table.php @@ -0,0 +1,51 @@ +id(); + $table->string('event_type')->index(); + $table->string('content_type')->nullable()->index(); + $table->unsignedBigInteger('content_id')->nullable()->index(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('visitor_id', 120)->nullable()->index(); + $table->string('session_id', 120)->nullable()->index(); + $table->text('url')->nullable(); + $table->string('route_name')->nullable()->index(); + $table->text('referrer')->nullable(); + $table->string('utm_source')->nullable()->index(); + $table->string('utm_medium')->nullable()->index(); + $table->string('utm_campaign')->nullable()->index(); + $table->string('device_type')->nullable()->index(); + $table->string('browser')->nullable(); + $table->string('platform')->nullable(); + $table->string('country_code', 8)->nullable()->index(); + $table->boolean('is_logged_in')->default(false)->index(); + $table->boolean('is_subscriber')->default(false)->index(); + $table->boolean('is_admin')->default(false)->index(); + $table->boolean('is_bot')->default(false)->index(); + $table->boolean('is_crawler')->default(false)->index(); + $table->boolean('is_suspicious')->default(false)->index(); + $table->json('metadata')->nullable(); + $table->timestamp('occurred_at')->index(); + $table->timestamps(); + + $table->index(['event_type', 'occurred_at']); + $table->index(['content_type', 'content_id', 'occurred_at'], 'academy_events_content_occurred_idx'); + $table->index(['user_id', 'occurred_at']); + $table->index(['visitor_id', 'occurred_at']); + $table->index(['is_bot', 'is_admin', 'occurred_at'], 'academy_events_bot_admin_occurred_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('academy_events'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_05_11_000002_create_academy_content_metrics_daily_table.php b/database/migrations/2026_05_11_000002_create_academy_content_metrics_daily_table.php new file mode 100644 index 00000000..19a9051b --- /dev/null +++ b/database/migrations/2026_05_11_000002_create_academy_content_metrics_daily_table.php @@ -0,0 +1,49 @@ +id(); + $table->date('date')->index(); + $table->string('content_type')->index(); + $table->unsignedBigInteger('content_id')->nullable()->index(); + $table->unsignedInteger('views')->default(0); + $table->unsignedInteger('unique_visitors')->default(0); + $table->unsignedInteger('guest_views')->default(0); + $table->unsignedInteger('user_views')->default(0); + $table->unsignedInteger('subscriber_views')->default(0); + $table->unsignedInteger('engaged_views')->default(0); + $table->unsignedInteger('scroll_50')->default(0); + $table->unsignedInteger('scroll_75')->default(0); + $table->unsignedInteger('scroll_100')->default(0); + $table->unsignedInteger('likes')->default(0); + $table->unsignedInteger('saves')->default(0); + $table->unsignedInteger('prompt_copies')->default(0); + $table->unsignedInteger('negative_prompt_copies')->default(0); + $table->unsignedInteger('starts')->default(0); + $table->unsignedInteger('completions')->default(0); + $table->unsignedInteger('upgrade_clicks')->default(0); + $table->unsignedInteger('premium_preview_views')->default(0); + $table->unsignedInteger('search_impressions')->default(0); + $table->unsignedInteger('search_clicks')->default(0); + $table->unsignedInteger('bounce_count')->default(0); + $table->unsignedInteger('avg_engaged_seconds')->nullable(); + $table->decimal('popularity_score', 12, 2)->default(0); + $table->decimal('conversion_score', 12, 2)->default(0); + $table->timestamps(); + + $table->unique(['date', 'content_type', 'content_id'], 'academy_metrics_daily_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('academy_content_metrics_daily'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_05_11_000003_create_academy_likes_table.php b/database/migrations/2026_05_11_000003_create_academy_likes_table.php new file mode 100644 index 00000000..b6a898c9 --- /dev/null +++ b/database/migrations/2026_05_11_000003_create_academy_likes_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('content_type')->index(); + $table->unsignedBigInteger('content_id')->index(); + $table->timestamps(); + + $table->unique(['user_id', 'content_type', 'content_id'], 'academy_likes_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('academy_likes'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_05_11_000004_create_academy_saves_table.php b/database/migrations/2026_05_11_000004_create_academy_saves_table.php new file mode 100644 index 00000000..80a828aa --- /dev/null +++ b/database/migrations/2026_05_11_000004_create_academy_saves_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('content_type')->index(); + $table->unsignedBigInteger('content_id')->index(); + $table->timestamps(); + + $table->unique(['user_id', 'content_type', 'content_id'], 'academy_saves_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('academy_saves'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_05_11_000005_create_academy_user_progress_table.php b/database/migrations/2026_05_11_000005_create_academy_user_progress_table.php new file mode 100644 index 00000000..aea6842e --- /dev/null +++ b/database/migrations/2026_05_11_000005_create_academy_user_progress_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('course_id')->nullable()->constrained('academy_courses')->nullOnDelete(); + $table->foreignId('lesson_id')->nullable()->constrained('academy_lessons')->nullOnDelete(); + $table->string('status')->index(); + $table->unsignedTinyInteger('progress_percent')->default(0); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('last_seen_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'course_id', 'lesson_id'], 'academy_user_progress_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('academy_user_progress'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_05_11_000006_create_academy_search_logs_table.php b/database/migrations/2026_05_11_000006_create_academy_search_logs_table.php new file mode 100644 index 00000000..d1e6d130 --- /dev/null +++ b/database/migrations/2026_05_11_000006_create_academy_search_logs_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('visitor_id', 120)->nullable()->index(); + $table->string('query')->index(); + $table->string('normalized_query')->index(); + $table->unsignedInteger('results_count')->default(0)->index(); + $table->string('clicked_content_type')->nullable()->index(); + $table->unsignedBigInteger('clicked_content_id')->nullable()->index(); + $table->json('filters')->nullable(); + $table->boolean('is_logged_in')->default(false)->index(); + $table->boolean('is_subscriber')->default(false)->index(); + $table->boolean('is_bot')->default(false)->index(); + $table->timestamps(); + + $table->index(['normalized_query', 'created_at']); + $table->index(['results_count', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('academy_search_logs'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_05_14_000001_add_advanced_fields_to_academy_prompt_templates_table.php b/database/migrations/2026_05_14_000001_add_advanced_fields_to_academy_prompt_templates_table.php new file mode 100644 index 00000000..67aa22c7 --- /dev/null +++ b/database/migrations/2026_05_14_000001_add_advanced_fields_to_academy_prompt_templates_table.php @@ -0,0 +1,47 @@ +json('documentation')->nullable()->after('workflow_notes'); + } + + if (! Schema::hasColumn('academy_prompt_templates', 'placeholders')) { + $table->json('placeholders')->nullable()->after('documentation'); + } + + if (! Schema::hasColumn('academy_prompt_templates', 'helper_prompts')) { + $table->json('helper_prompts')->nullable()->after('placeholders'); + } + + if (! Schema::hasColumn('academy_prompt_templates', 'prompt_variants')) { + $table->json('prompt_variants')->nullable()->after('helper_prompts'); + } + }); + } + + public function down(): void + { + Schema::table('academy_prompt_templates', function (Blueprint $table): void { + $columns = array_values(array_filter([ + Schema::hasColumn('academy_prompt_templates', 'documentation') ? 'documentation' : null, + Schema::hasColumn('academy_prompt_templates', 'placeholders') ? 'placeholders' : null, + Schema::hasColumn('academy_prompt_templates', 'helper_prompts') ? 'helper_prompts' : null, + Schema::hasColumn('academy_prompt_templates', 'prompt_variants') ? 'prompt_variants' : null, + ])); + + if ($columns !== []) { + $table->dropColumn($columns); + } + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_05_14_000001_add_created_at_indexes_to_activity_feed_tables.php b/database/migrations/2026_05_14_000001_add_created_at_indexes_to_activity_feed_tables.php new file mode 100644 index 00000000..2f4eae05 --- /dev/null +++ b/database/migrations/2026_05_14_000001_add_created_at_indexes_to_activity_feed_tables.php @@ -0,0 +1,30 @@ +index(['created_at', 'id'], 'idx_comment_reactions_created_at'); + }); + + Schema::table('user_mentions', function (Blueprint $table): void { + $table->index(['created_at', 'id'], 'idx_user_mentions_created_at'); + }); + } + + public function down(): void + { + Schema::table('user_mentions', function (Blueprint $table): void { + $table->dropIndex('idx_user_mentions_created_at'); + }); + + Schema::table('comment_reactions', function (Blueprint $table): void { + $table->dropIndex('idx_comment_reactions_created_at'); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_05_19_104802_create_customer_columns.php b/database/migrations/2026_05_19_104802_create_customer_columns.php new file mode 100644 index 00000000..974b381e --- /dev/null +++ b/database/migrations/2026_05_19_104802_create_customer_columns.php @@ -0,0 +1,40 @@ +string('stripe_id')->nullable()->index(); + $table->string('pm_type')->nullable(); + $table->string('pm_last_four', 4)->nullable(); + $table->timestamp('trial_ends_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropIndex([ + 'stripe_id', + ]); + + $table->dropColumn([ + 'stripe_id', + 'pm_type', + 'pm_last_four', + 'trial_ends_at', + ]); + }); + } +}; diff --git a/database/migrations/2026_05_19_104803_create_subscriptions_table.php b/database/migrations/2026_05_19_104803_create_subscriptions_table.php new file mode 100644 index 00000000..ccbcc6dd --- /dev/null +++ b/database/migrations/2026_05_19_104803_create_subscriptions_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('user_id'); + $table->string('type'); + $table->string('stripe_id')->unique(); + $table->string('stripe_status'); + $table->string('stripe_price')->nullable(); + $table->integer('quantity')->nullable(); + $table->timestamp('trial_ends_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'stripe_status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('subscriptions'); + } +}; diff --git a/database/migrations/2026_05_19_104804_create_subscription_items_table.php b/database/migrations/2026_05_19_104804_create_subscription_items_table.php new file mode 100644 index 00000000..420e23f0 --- /dev/null +++ b/database/migrations/2026_05_19_104804_create_subscription_items_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('subscription_id'); + $table->string('stripe_id')->unique(); + $table->string('stripe_product'); + $table->string('stripe_price'); + $table->integer('quantity')->nullable(); + $table->timestamps(); + + $table->index(['subscription_id', 'stripe_price']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('subscription_items'); + } +}; diff --git a/database/migrations/2026_05_19_104805_add_meter_id_to_subscription_items_table.php b/database/migrations/2026_05_19_104805_add_meter_id_to_subscription_items_table.php new file mode 100644 index 00000000..033bb829 --- /dev/null +++ b/database/migrations/2026_05_19_104805_add_meter_id_to_subscription_items_table.php @@ -0,0 +1,28 @@ +string('meter_id')->nullable()->after('stripe_price'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subscription_items', function (Blueprint $table) { + $table->dropColumn('meter_id'); + }); + } +}; diff --git a/database/migrations/2026_05_19_104806_add_meter_event_name_to_subscription_items_table.php b/database/migrations/2026_05_19_104806_add_meter_event_name_to_subscription_items_table.php new file mode 100644 index 00000000..b157b3a5 --- /dev/null +++ b/database/migrations/2026_05_19_104806_add_meter_event_name_to_subscription_items_table.php @@ -0,0 +1,28 @@ +string('meter_event_name')->nullable()->after('quantity'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subscription_items', function (Blueprint $table) { + $table->dropColumn('meter_event_name'); + }); + } +}; diff --git a/database/migrations/2026_05_19_120000_create_academy_billing_events_table.php b/database/migrations/2026_05_19_120000_create_academy_billing_events_table.php new file mode 100644 index 00000000..9bd8f1d1 --- /dev/null +++ b/database/migrations/2026_05_19_120000_create_academy_billing_events_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('stripe_event_id')->nullable()->unique(); + $table->string('stripe_customer_id')->nullable()->index(); + $table->string('stripe_subscription_id')->nullable()->index(); + $table->string('event_type'); + $table->string('academy_tier')->nullable(); + $table->string('academy_plan')->nullable(); + $table->json('payload_summary')->nullable(); + $table->timestamp('processed_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('academy_billing_events'); + } +}; \ No newline at end of file diff --git a/docs/academy-billing-production.md b/docs/academy-billing-production.md new file mode 100644 index 00000000..0c251577 --- /dev/null +++ b/docs/academy-billing-production.md @@ -0,0 +1,105 @@ +# Academy Billing Production Rollout + +Last updated: 2026-05-19 + +This note covers the remaining non-code steps required to move Academy Stripe billing from implemented-in-app to production-ready. + +## Readiness Check + +Run the local readiness command before enabling the rollout flag: + +```bash +php artisan academy:billing-health +php artisan academy:billing-health --json +php artisan academy:billing-health --strict +``` + +What it checks: +- Stripe publishable key, secret key, and webhook secret are configured and not placeholder values +- Academy billing price IDs are present for all configured plans +- Cashier webhook and Academy billing routes are registered +- Billing tables and Cashier user columns exist locally +- Moderation Academy billing overview route is available + +`--strict` exits non-zero when blocking issues are found, which makes it suitable for CI or pre-deploy checks. + +## Environment Variables + +The following values must be set with real production values before enabling Academy billing: + +```dotenv +SKINBASE_ACADEMY_ENABLED=true +ACADEMY_BILLING_ENABLED=true +ACADEMY_STRIPE_SUBSCRIPTION_NAME=academy + +STRIPE_KEY=pk_live_... +STRIPE_SECRET=sk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... +CASHIER_CURRENCY=eur +CASHIER_CURRENCY_LOCALE=sl_SI + +ACADEMY_CREATOR_MONTHLY_PRICE_ID=price_... +ACADEMY_PRO_MONTHLY_PRICE_ID=price_... +``` + +Do not enable `ACADEMY_BILLING_ENABLED=true` until the webhook endpoint and Billing Portal are configured in Stripe. + +## Stripe Dashboard Steps + +Create these products: +- Skinbase Academy Creator +- Skinbase Academy Pro + +Create these recurring prices: +- `creator_monthly` +- `pro_monthly` + +Configure the production webhook endpoint: +- URL: `https://skinbase.org/stripe/webhook` +- Signing secret: copy into `STRIPE_WEBHOOK_SECRET` + +Recommended subscribed events: +- `checkout.session.completed` +- `customer.subscription.created` +- `customer.subscription.updated` +- `customer.subscription.deleted` +- `customer.updated` +- `customer.deleted` +- `payment_method.automatically_updated` +- `invoice.payment_succeeded` +- `invoice.payment_failed` +- `invoice.payment_action_required` + +Configure Stripe Billing Portal: +- Allow payment method updates +- Allow plan changes +- Allow cancellation +- Allow invoice history access + +## Deployment Sequence + +1. Deploy code and migrations. +2. Populate production env with live Stripe values and production price IDs. +3. Run `php artisan academy:billing-health --strict` on the target environment. +4. Confirm the webhook endpoint is reachable and signature verification is active. +5. Test a full Stripe test-mode or low-risk live transaction. +6. Verify the subscription appears in local Cashier tables and Academy access updates from synced state. +7. Verify `/moderation/academy/billing` shows the expected audit event and subscription counts. +8. Enable `ACADEMY_BILLING_ENABLED=true` only after the prior checks pass. + +## Smoke Test Checklist + +After rollout: +- Guest can view Academy pricing +- Guest cannot start checkout +- Verified user can start checkout +- Success page does not grant access by itself +- Webhook sync creates or updates the local subscription rows +- Creator plan unlocks creator content but not pro content +- Pro plan unlocks creator and pro content +- Billing Portal opens and returns to `/academy/billing` +- Canceling a subscription keeps access during grace period and removes it after end + +## Operational Note + +The moderation billing overview at `/moderation/academy/billing` is visibility-only. It is not a manual entitlement system and should not be used to bypass Stripe billing state. \ No newline at end of file diff --git a/docs/cli-reference.md b/docs/cli-reference.md index d00d85cf..d9e5bf9f 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1,6 +1,6 @@ # Skinbase26 CLI Reference -Last updated: 2026-04-25 +Last updated: 2026-05-19 This document lists the repository-specific command-line entry points in Skinbase26. @@ -20,14 +20,14 @@ Examples below are representative. For the full option list of any Artisan comma | Entry point | Why it is used | Example | | --- | --- | --- | | `php artisan` | Main Laravel CLI for all custom app commands listed below | `php artisan list --raw` | -| `bash sync.sh` | Main production deploy wrapper; delegates to the production deploy script | `bash sync.sh` | +| `bash sync.sh` | Main production deploy wrapper; delegates to the safe release-based production deploy script | `bash sync.sh` | | `bash sync_dev.sh` | Push the development environment to the configured remote dev host | `bash sync_dev.sh` | ## Maintained Standalone Scripts | Script | Why it is used | Example | | --- | --- | --- | -| `scripts/deploy-production.sh` | Full production deployment workflow using retained release directories and a server-side current-release switch | `bash scripts/deploy-production.sh --mode=normal` | +| `scripts/deploy-production.sh` | Safe production deployment wrapper that resolves to the retained-release deploy workflow with a server-side current-release switch | `bash scripts/deploy-production.sh --mode=normal` | | `scripts/rollback-production.sh` | Switch the active production release to a previously retained server-side deployment | `bash scripts/rollback-production.sh --previous` | | `scripts/push-db-to-prod.sh` | Push the local database to production with remote backup controls | `bash scripts/push-db-to-prod.sh --force` | | `scripts/render-nova-card.cjs` | Render a Nova card screenshot through Playwright | `node scripts/render-nova-card.cjs --url=https://example.test/card --out=tmp/card.png` | @@ -57,6 +57,14 @@ Examples below are representative. For the full option list of any Artisan comma | `ai-biography:review-queue` | List AI biography records that need manual review | `php artisan ai-biography:review-queue --needs-review --limit=25` | | `ai-biography:validate` | Revalidate stored AI biographies against the current rules | `php artisan ai-biography:validate --limit=100 --dry-run` | +### Academy + +| Command | Why it is used | Example | +| --- | --- | --- | +| `academy:analytics-health` | Inspect Academy analytics collection health, rollup freshness, and privacy safeguards | `php artisan academy:analytics-health --json` | +| `academy:billing-health` | Check Academy Stripe billing rollout readiness, including Stripe secrets, price IDs, Cashier routes, and billing tables | `php artisan academy:billing-health --strict` | +| `academy:courses:sync-foundations` | Create or update the default AI-Assisted Digital Art Foundations Academy course | `php artisan academy:courses:sync-foundations` | + ### Artworks | Command | Why it is used | Example | diff --git a/docs/deployment.md b/docs/deployment.md index 79de11e1..62b03764 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -10,6 +10,8 @@ Run the existing entrypoint: bash sync.sh ``` +`bash sync.sh` delegates to the safe production deploy wrapper, which stages each deploy into a versioned release directory and switches traffic by updating the server-side `current` symlink. + If you launch `bash sync.sh` from WSL against this Windows checkout, the script will automatically run the frontend build with `npm.cmd` on Windows so Rollup/Vite use the correct optional native package set. This will: diff --git a/new_passwords.7z b/new_passwords.7z deleted file mode 100644 index f9370345..00000000 Binary files a/new_passwords.7z and /dev/null differ diff --git a/projekti_2026_skinbase.7z b/projekti_2026_skinbase.7z deleted file mode 100644 index 9277f2a1..00000000 Binary files a/projekti_2026_skinbase.7z and /dev/null differ diff --git a/public/sitemaps/web-stories.xml b/public/sitemaps/web-stories.xml new file mode 100644 index 00000000..2943c905 --- /dev/null +++ b/public/sitemaps/web-stories.xml @@ -0,0 +1,10 @@ + + + http://skinbase26.test/web-stories/hello-again + 2026-05-08T22:17:44+02:00 + + https://cdn.skinbase.org/web-stories/worlds/et-ea-magni/poster-portrait.webp + Et Ea Magni + + + \ No newline at end of file diff --git a/resources/js/Layouts/AdminLayout.jsx b/resources/js/Layouts/AdminLayout.jsx index df7436d4..137d395a 100644 --- a/resources/js/Layouts/AdminLayout.jsx +++ b/resources/js/Layouts/AdminLayout.jsx @@ -24,6 +24,7 @@ const buildAdminNavGroups = (isAdmin) => [ { label: 'Stories', href: '/moderation/stories', icon: 'fa-solid fa-feather-pointed' }, { label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images' }, { label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star' }, + { label: 'Web Stories', href: '/moderation/web-stories', icon: 'fa-solid fa-book-open-reader' }, { label: 'Homepage Announcements', href: '/moderation/homepage/announcements', icon: 'fa-solid fa-bullhorn' }, { label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up' }, { label: 'Username Queue', href: '/moderation/usernames/moderation', icon: 'fa-solid fa-id-badge' }, @@ -34,6 +35,8 @@ const buildAdminNavGroups = (isAdmin) => [ label: 'Academy', items: [ { label: 'Academy Dashboard', href: '/moderation/academy/dashboard', icon: 'fa-solid fa-graduation-cap' }, + { label: 'Academy Billing', href: '/moderation/academy/billing', icon: 'fa-solid fa-credit-card' }, + { label: 'Academy Analytics', href: '/moderation/academy/analytics', icon: 'fa-solid fa-chart-line' }, { label: 'Academy Courses', href: '/moderation/academy/courses', icon: 'fa-solid fa-road' }, { label: 'Academy Lessons', href: '/moderation/academy/lessons', icon: 'fa-solid fa-book-open' }, { label: 'Academy Prompts', href: '/moderation/academy/prompts', icon: 'fa-solid fa-wand-magic-sparkles' }, diff --git a/resources/js/Pages/Academy/Billing/Account.jsx b/resources/js/Pages/Academy/Billing/Account.jsx new file mode 100644 index 00000000..2aef09ae --- /dev/null +++ b/resources/js/Pages/Academy/Billing/Account.jsx @@ -0,0 +1,152 @@ +import React from 'react' +import { Head, Link } from '@inertiajs/react' +import AccessBadge from '../../../components/academy/billing/AccessBadge' + +function formatDate(iso) { + if (!iso) return null + try { + return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) + } catch { + return null + } +} + +export default function AcademyBillingAccount({ currentTier, isSubscribed, subscription, activePlan = null, links = {} }) { + const endsAt = formatDate(subscription?.endsAt) + const onGracePeriod = subscription?.onGracePeriod === true + const subscriptionActive = subscription?.active === true + + return ( +
+ + +
+ {/* Header */} +
+
+

Skinbase Academy

+ +
+

+ {isSubscribed ? 'Your subscription' : 'Academy subscription'} +

+

+ {isSubscribed + ? 'Your Academy access is active. Manage, upgrade, or cancel your subscription here at any time.' + : 'You are on the free Academy tier. Upgrade to Creator or Pro to unlock premium content.'} +

+
+ + {/* Grace period warning */} + {onGracePeriod && endsAt ? ( +
+

Your subscription was cancelled and will end on {endsAt}.

+

You still have full access until that date. Open the subscription portal to resume your plan if you change your mind.

+ + Resume subscription + +
+ ) : null} + + {/* No subscription: upgrade CTA */} + {!isSubscribed ? ( +
+

Upgrade

+

Choose a plan to get started

+

+ Creator unlocks premium lessons and the full prompt library for €4.99/month. Pro gives you everything — all lessons, the advanced content track, and every new Academy drop — for €9.99/month. +

+
+ + See plans and pricing + + + Back to Academy + +
+
+ ) : null} + + {/* Active subscription: details + manager */} + {isSubscribed ? ( +
+
+

Subscription details

+ +
+
+

Active plan

+

{activePlan?.label || 'Academy plan'}

+ {activePlan?.price_display ? ( +

{activePlan.price_display} / month

+ ) : null} +
+ +
+

Status

+

+ {onGracePeriod ? 'Cancelling' : subscriptionActive ? 'Active' : (subscription?.status || 'Active')} +

+ {onGracePeriod && endsAt ? ( +

Access ends {endsAt}

+ ) : null} + {!onGracePeriod && subscriptionActive ? ( +

Renews automatically

+ ) : null} +
+
+ +
+

Your Academy access

+
+ +

+ {currentTier === 'pro' + ? 'Full access to all Academy lessons and content.' + : currentTier === 'creator' + ? 'Full access to all Creator lessons and prompts.' + : 'Access to free Academy content.'} +

+
+
+
+ + +
+ ) : null} +
+
+ ) +} \ No newline at end of file diff --git a/resources/js/Pages/Academy/Billing/Cancel.jsx b/resources/js/Pages/Academy/Billing/Cancel.jsx new file mode 100644 index 00000000..c0171175 --- /dev/null +++ b/resources/js/Pages/Academy/Billing/Cancel.jsx @@ -0,0 +1,23 @@ +import React from 'react' +import { Head, Link } from '@inertiajs/react' + +export default function AcademyBillingCancel({ message, links = {} }) { + return ( +
+ + +
+
+

Checkout canceled

+

No payment was made.

+

{message}

+
+ +
+ Return to pricing + Back to Academy +
+
+
+ ) +} \ No newline at end of file diff --git a/resources/js/Pages/Academy/Billing/Pricing.jsx b/resources/js/Pages/Academy/Billing/Pricing.jsx new file mode 100644 index 00000000..4210af8e --- /dev/null +++ b/resources/js/Pages/Academy/Billing/Pricing.jsx @@ -0,0 +1,220 @@ +import React from 'react' +import { usePage, Link } from '@inertiajs/react' +import SeoHead from '../../../components/seo/SeoHead' +import AccessBadge from '../../../components/academy/billing/AccessBadge' +import PlanCard from '../../../components/academy/billing/PlanCard' +import { trackUpgradeClick, useAcademyPageAnalytics } from '../../../lib/academyAnalytics' + +function getCsrfToken() { + return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' +} + +function heroText(currentTier, isSubscribed) { + if (isSubscribed && currentTier === 'pro') { + return { + heading: 'You have full Academy access.', + body: 'All lessons, prompts, and Academy content are unlocked on your Pro plan. To upgrade, downgrade, or cancel, use the subscription manager below.', + } + } + if (isSubscribed && currentTier === 'creator') { + return { + heading: "You're on the Creator plan.", + body: 'Creator content is fully unlocked. Upgrade to Pro anytime to access the advanced lesson track and everything new that launches at the Pro tier.', + } + } + if (currentTier === 'admin') { + return { + heading: 'Academy plans.', + body: 'Your admin account already has full Academy access. Browse the plans below.', + } + } + if (isSubscribed) { + return { + heading: 'Manage your Academy subscription.', + body: 'Your plan is active. Review your options below or use the subscription manager to make changes.', + } + } + return { + heading: 'Unlock everything in Academy.', + body: "Start free and upgrade when you're ready. Creator unlocks premium lessons and the full prompt library. Pro adds the advanced lesson track and is the highest Academy tier.", + } +} + +function SidePanel({ currentTier, isSubscribed, activePlanLabel, activePlanPrice, manageHref }) { + if (isSubscribed) { + return ( +
+

Your subscription

+
+
+

Active plan

+

{activePlanLabel || 'Academy plan'}

+
+
+

Billed monthly

+

{activePlanPrice || '—'}

+
+
+ {manageHref ? ( + + Manage subscription + + ) : null} +
+ ) + } + + return ( +
+

Why upgrade?

+
+ {[ + { title: 'Instant access', body: 'Subscription activates the moment payment is confirmed.' }, + { title: 'Cancel anytime', body: 'No lock-in. Keep access until the end of the billing period.' }, + { title: 'Switch freely', body: 'Move between Creator and Pro from your subscription manager.' }, + ].map(({ title, body }) => ( +
+

{title}

+

{body}

+
+ ))} +
+
+ ) +} + +export default function AcademyBillingPricing({ seo, billingEnabled, currentTier, isSubscribed, activePlanKey = null, activePlanLabel = null, catalog = [], links = {}, analytics }) { + const { auth, errors, flash } = usePage().props + + useAcademyPageAnalytics(analytics) + + const loginHref = auth?.user ? null : `${links.login || '/login'}?intended=${encodeURIComponent(links.pricing || '/academy/pricing')}` + + const products = catalog.map((product) => ({ + ...product, + selectedPlan: product.plans[0] || null, + })) + + const activePlanPrice = products + .flatMap((p) => p.plans) + .find((p) => p?.key === activePlanKey)?.price_display || null + + const handleCheckout = (plan) => { + if (!plan?.key || !links.checkout) return + + trackUpgradeClick(analytics, { + source: 'academy_billing_pricing', + academy_plan: plan.key, + academy_interval: plan.interval, + }) + + const form = document.createElement('form') + form.method = 'POST' + form.action = links.checkout + form.style.display = 'none' + + const csrfInput = document.createElement('input') + csrfInput.type = 'hidden' + csrfInput.name = '_token' + csrfInput.value = getCsrfToken() + + const planInput = document.createElement('input') + planInput.type = 'hidden' + planInput.name = 'plan' + planInput.value = plan.key + + form.appendChild(csrfInput) + form.appendChild(planInput) + document.body.appendChild(form) + form.submit() + } + + const hero = heroText(currentTier, isSubscribed) + const showFreeBadgeAsCurrentPlan = currentTier === 'free' && !isSubscribed && auth?.user + + return ( +
+ + +
+ {/* Hero */} +
+
+
+
+

Skinbase Academy

+ {currentTier !== 'free' ? : null} +
+

{hero.heading}

+

{hero.body}

+ + {errors?.plan ?

{errors.plan}

: null} + {flash?.error ?

{flash.error}

: null} + {flash?.success ?

{flash.success}

: null} +
+ + +
+
+ + {/* Plan grid */} +
+ {/* Free / Explorer card */} +
+
+
+

Free

+

Explorer

+
+ {showFreeBadgeAsCurrentPlan + ? Your plan + : } +
+ +
+

Free

+

No payment needed

+
+ +

Everything you need to explore Academy, follow public lessons, and see a preview of what the paid tiers include.

+ +
+ {[ + 'Public lessons and Academy listings', + 'Prompt previews and public documentation', + 'Community access and updates', + 'Upgrade to Creator or Pro anytime', + ].map((feature) => ( +
+ + {feature} +
+ ))} +
+
+ + {products.map((product) => ( + + ))} +
+
+
+ ) +} \ No newline at end of file diff --git a/resources/js/Pages/Academy/Billing/Success.jsx b/resources/js/Pages/Academy/Billing/Success.jsx new file mode 100644 index 00000000..6d47a629 --- /dev/null +++ b/resources/js/Pages/Academy/Billing/Success.jsx @@ -0,0 +1,45 @@ +import React from 'react' +import { Head, Link } from '@inertiajs/react' +import AccessBadge from '../../../components/academy/billing/AccessBadge' + +export default function AcademyBillingSuccess({ currentTier, isSubscribed, links = {} }) { + return ( +
+ + +
+
+
+ 🎉 + {isSubscribed ? : null} +
+

+ {isSubscribed ? 'Welcome to Academy.' : "You're all set."} +

+

+ {isSubscribed + ? 'Your subscription is active and all premium content for your plan is now unlocked. Head to Academy and start exploring.' + : "Your payment was confirmed and your subscription is activating now. This usually takes just a moment. If you don't see your access right away, refresh the Academy page in a few seconds."} +

+
+ +
+ + Go to Academy + + {links.account ? ( + + View my subscription + + ) : null} +
+
+
+ ) +} \ No newline at end of file diff --git a/resources/js/Pages/Academy/CoursesIndex.jsx b/resources/js/Pages/Academy/CoursesIndex.jsx index 9b7aa3d9..42df2250 100644 --- a/resources/js/Pages/Academy/CoursesIndex.jsx +++ b/resources/js/Pages/Academy/CoursesIndex.jsx @@ -2,15 +2,33 @@ import React from 'react' import { Link, router, usePage } from '@inertiajs/react' import SeoHead from '../../components/seo/SeoHead' import NovaSelect from '../../components/ui/NovaSelect' +import { trackAcademySearchResultClick, trackUpgradeClick, useAcademyPageAnalytics } from '../../lib/academyAnalytics' -function CourseCard({ course, variant = 'default' }) { +function CourseCard({ course, variant = 'default', analytics = null, searchContext = null, position = null }) { const isFeatured = variant === 'featured' const progress = course?.progress || null const cover = course?.cover_image_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image || '' + const trackSearchClick = () => { + if (!searchContext?.query) { + return + } + + trackAcademySearchResultClick(analytics, searchContext, { + contentType: 'academy_course', + contentId: course?.id, + position, + }) + } return ( {title}

{description}

- See Academy plans + trackUpgradeClick(analytics, { source: 'academy_courses_index_hero' })} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100">See Academy plans @@ -86,9 +111,9 @@ export default function AcademyCoursesIndex({ seo, title, description, items, fe {featuredCourses.length ? (
- +
- {featuredCourses.slice(1, 3).map((course) => )} + {featuredCourses.slice(1, 3).map((course, index) => )}
) : null} @@ -116,7 +141,7 @@ export default function AcademyCoursesIndex({ seo, title, description, items, fe
No published Academy courses matched these filters.
) : (
- {items.data.map((course) => )} + {items.data.map((course, index) => )}
)} diff --git a/resources/js/Pages/Academy/CoursesShow.jsx b/resources/js/Pages/Academy/CoursesShow.jsx index a296e1d9..3323300f 100644 --- a/resources/js/Pages/Academy/CoursesShow.jsx +++ b/resources/js/Pages/Academy/CoursesShow.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react' -import { Link, usePage } from '@inertiajs/react' +import { Link, router, usePage } from '@inertiajs/react' import SeoHead from '../../components/seo/SeoHead' +import { postAcademyAction, trackUpgradeClick, useAcademyPageAnalytics } from '../../lib/academyAnalytics' function CourseBreadcrumbs({ items = [] }) { if (!items.length) return null @@ -197,10 +198,15 @@ function SectionBlock({ section, isActive = false }) { ) } -export default function AcademyCoursesShow({ seo, course, sections = [], unsectionedLessons = [], pricingUrl }) { +export default function AcademyCoursesShow({ seo, course, sections = [], unsectionedLessons = [], pricingUrl, startUrl = null, interaction = null, interactionRoutes = null, loginUrl = null, analytics = null }) { const flash = usePage().props.flash || {} + useAcademyPageAnalytics(analytics) const cover = course?.cover_image_url || course?.cover_image || course?.teaser_image_url || course?.teaser_image || '' const progress = course?.progress || null + const [liked, setLiked] = useState(Boolean(interaction?.liked)) + const [saved, setSaved] = useState(Boolean(interaction?.saved)) + const [likesCount, setLikesCount] = useState(Number(interaction?.likes_count || 0)) + const [savesCount, setSavesCount] = useState(Number(interaction?.saves_count || 0)) const sectionJumpItems = useMemo( () => [ @@ -245,6 +251,63 @@ export default function AcademyCoursesShow({ seo, course, sections = [], unsecti return () => observer.disconnect() }, [sectionJumpItems]) + const requireLogin = () => { + if (loginUrl && typeof window !== 'undefined') { + window.location.href = loginUrl + } + } + + const startCourse = () => { + if (!startUrl) { + requireLogin() + return + } + + router.post(startUrl) + } + + const toggleLike = async () => { + if (!interactionRoutes?.like || !analytics?.contentType || !analytics?.contentId) { + return + } + + if (analytics?.isGuest) { + requireLogin() + return + } + + const payload = await postAcademyAction(interactionRoutes.like, { + content_type: analytics.contentType, + content_id: analytics.contentId, + }) + + if (payload?.liked !== undefined) { + setLiked(Boolean(payload.liked)) + setLikesCount(Number(payload.likes_count || 0)) + } + } + + const toggleSave = async () => { + if (!interactionRoutes?.save || !analytics?.contentType || !analytics?.contentId) { + return + } + + if (analytics?.isGuest) { + requireLogin() + return + } + + const payload = await postAcademyAction(interactionRoutes.save, { + content_type: analytics.contentType, + content_id: analytics.contentId, + }) + + if (payload?.saved !== undefined) { + setSaved(Boolean(payload.saved)) + setSavesCount(Number(payload.saves_count || 0)) + } + } + return (
@@ -273,6 +336,13 @@ export default function AcademyCoursesShow({ seo, course, sections = [], unsecti {course?.subtitle ?

{course.subtitle}

: null}

{course?.excerpt || course?.description}

+
+ + + + trackUpgradeClick(analytics, { source: 'academy_course_header' })} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100">See plans +
+
{cover ? ( diff --git a/resources/js/Pages/Academy/Index.jsx b/resources/js/Pages/Academy/Index.jsx index 20ed3703..04303336 100644 --- a/resources/js/Pages/Academy/Index.jsx +++ b/resources/js/Pages/Academy/Index.jsx @@ -1,6 +1,7 @@ import React from 'react' import { Link } from '@inertiajs/react' import SeoHead from '../../components/seo/SeoHead' +import { trackUpgradeClick, useAcademyPageAnalytics } from '../../lib/academyAnalytics' function academyHref(section, slug) { return `/academy/${section}/${encodeURIComponent(slug)}` @@ -39,7 +40,9 @@ function FeaturedCourseCard({ course }) { ) } -export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, stats, featuredCourses, featuredLessons, featuredPrompts, featuredChallenges }) { +export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, stats, featuredCourses, featuredLessons, featuredPrompts, featuredChallenges, analytics }) { + useAcademyPageAnalytics(analytics) + const jsonLd = [{ '@context': 'https://schema.org', '@type': 'WebPage', @@ -64,7 +67,7 @@ export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, sta Browse courses Browse lessons Open prompt library - See plans + trackUpgradeClick(analytics, { source: 'academy_home_hero' })} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">See plans
diff --git a/resources/js/Pages/Academy/List.jsx b/resources/js/Pages/Academy/List.jsx index 4a723eb8..173b7fef 100644 --- a/resources/js/Pages/Academy/List.jsx +++ b/resources/js/Pages/Academy/List.jsx @@ -74,13 +74,27 @@ function searchResultContentType(pageType) { return null } +function promptPreviewAsset(item) { + const full = item?.preview_image || '' + const thumb = item?.preview_image_thumb || full + + if (!thumb) { + return null + } + + return { + src: thumb, + srcSet: item?.preview_image_srcset || '', + } +} + function PromptLibraryHero({ title, description, items, pricingUrl, totalCount }) { const featuredImages = (items || []) - .map((item) => item?.preview_image) + .map((item) => promptPreviewAsset(item)) .filter(Boolean) .slice(0, 3) - const primaryImage = featuredImages[0] || '' + const primaryImage = featuredImages[0] || null const supportingImages = featuredImages.slice(1, 3) return ( @@ -119,14 +133,14 @@ function PromptLibraryHero({ title, description, items, pricingUrl, totalCount } {primaryImage ? ( <>
- +
{supportingImages.length ? (
{supportingImages.map((image, index) => ( -
- +
+
))}
@@ -145,7 +159,8 @@ function PromptLibraryHero({ title, description, items, pricingUrl, totalCount } function AcademyCard({ pageType, item, analytics, searchContext, position }) { const lessonSeries = String(item?.series_name || '').trim() - const promptPreviewImage = item?.preview_image || '' + const promptPreviewImage = item?.preview_image_thumb || item?.preview_image || '' + const promptPreviewSrcSet = item?.preview_image_srcset || '' const contentType = searchResultContentType(pageType) const href = itemHref(pageType, item) const trackSearchClick = () => { @@ -173,7 +188,7 @@ function AcademyCard({ pageType, item, analytics, searchContext, position }) { className="group overflow-hidden rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(7,11,18,0.96))] shadow-[0_20px_50px_rgba(2,6,23,0.18)] transition hover:border-sky-300/25 hover:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(10,15,26,0.98))]" >
- {promptPreviewImage ? : null} + {promptPreviewImage ? : null}
Prompt template @@ -260,6 +275,7 @@ export default function AcademyList({ pageType, title, description, seo, items, const [pagination, setPagination] = React.useState({ currentPage: Number(items?.current_page || 1), lastPage: Number(items?.last_page || 1), + prevPageUrl: items?.prev_page_url || null, nextPageUrl: items?.next_page_url || null, }) const [loadingMore, setLoadingMore] = React.useState(false) @@ -270,12 +286,14 @@ export default function AcademyList({ pageType, title, description, seo, items, setPagination({ currentPage: Number(items?.current_page || 1), lastPage: Number(items?.last_page || 1), + prevPageUrl: items?.prev_page_url || null, nextPageUrl: items?.next_page_url || null, }) setLoadingMore(false) - }, [initialItems, items?.current_page, items?.last_page, items?.next_page_url, pageType]) + }, [initialItems, items?.current_page, items?.last_page, items?.next_page_url, items?.prev_page_url, pageType]) const hasMorePages = pageType === 'prompts' && pagination.currentPage < pagination.lastPage && Boolean(pagination.nextPageUrl) + const hasFallbackPagination = pageType === 'prompts' && pagination.lastPage > 1 const loadMore = React.useCallback(async () => { if (pageType !== 'prompts' || loadingMore || !pagination.nextPageUrl) { @@ -292,6 +310,7 @@ export default function AcademyList({ pageType, title, description, seo, items, setPagination({ currentPage: Number(payload?.current_page || pagination.currentPage), lastPage: Number(payload?.last_page || pagination.lastPage), + prevPageUrl: payload?.prev_page_url || pagination.prevPageUrl, nextPageUrl: payload?.next_page_url || null, }) } catch { @@ -299,7 +318,7 @@ export default function AcademyList({ pageType, title, description, seo, items, } finally { setLoadingMore(false) } - }, [loadingMore, pageType, pagination.currentPage, pagination.lastPage, pagination.nextPageUrl]) + }, [loadingMore, pageType, pagination.currentPage, pagination.lastPage, pagination.nextPageUrl, pagination.prevPageUrl]) React.useEffect(() => { const sentinel = sentinelRef.current @@ -355,6 +374,26 @@ export default function AcademyList({ pageType, title, description, seo, items, ) : null} diff --git a/resources/js/Pages/Academy/Pricing.jsx b/resources/js/Pages/Academy/Pricing.jsx deleted file mode 100644 index f9d5fee3..00000000 --- a/resources/js/Pages/Academy/Pricing.jsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react' -import SeoHead from '../../components/seo/SeoHead' - -function PlanCard({ plan, paymentsEnabled }) { - return ( -
-
-

{plan.name}

- {plan.badge} -
-
- {plan.price} - {plan.interval} -
-
- {plan.features.map((feature) => ( -
{feature}
- ))} -
- -
- ) -} - -export default function AcademyPricing({ seo, plans, paymentsEnabled }) { - return ( -
- - -
-
-

Plans

-

Choose your AI Academy plan.

-

Start free, unlock Creator and Pro previews, and keep the billing flow disabled until Stripe and Cashier are introduced in the next phase.

-
- -
- {plans.map((plan) => ( - - ))} -
-
-
- ) -} \ No newline at end of file diff --git a/resources/js/Pages/Academy/Show.jsx b/resources/js/Pages/Academy/Show.jsx index 950dbaf0..c170ba0b 100644 --- a/resources/js/Pages/Academy/Show.jsx +++ b/resources/js/Pages/Academy/Show.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from 'react' import { Link, router, usePage } from '@inertiajs/react' import SeoHead from '../../components/seo/SeoHead' +import { postAcademyAction, trackAcademyEvent, trackUpgradeClick, useAcademyPageAnalytics } from '../../lib/academyAnalytics' function academyHref(section, slug) { return `/academy/${section}/${encodeURIComponent(slug)}` @@ -59,11 +60,88 @@ function formatLessonMinutes(minutes) { return value > 0 ? `${value} min read` : 'Quick read' } -function StatPill({ label, value }) { +function normalizePromptAccessLevel(accessLevel) { + const value = String(accessLevel || 'free').trim().toLowerCase() + return value === 'creator' || value === 'pro' ? value : 'free' +} + +function promptRequirementText(accessLevel) { + const level = normalizePromptAccessLevel(accessLevel) + + if (level === 'pro') return 'Requires Pro access.' + if (level === 'creator') return 'Requires Creator or Pro access.' + + return null +} + +function promptUnlockHeading(accessLevel) { + const level = normalizePromptAccessLevel(accessLevel) + + if (level === 'pro') return 'Unlock the full Pro prompt.' + if (level === 'creator') return 'Unlock the full Creator prompt.' + + return 'Unlock the full prompt.' +} + +function promptUnlockDescription(accessLevel) { + const level = normalizePromptAccessLevel(accessLevel) + + if (level === 'pro') { + return 'Get the complete reusable prompt, negative prompt, workflow notes, model settings, and variation strategy.' + } + + if (level === 'creator') { + return 'Get the complete reusable prompt, negative prompt, workflow notes, and creative workflow.' + } + + return 'Get the complete reusable prompt and workflow notes.' +} + +function promptInlineImage(url, thumbUrl) { + return thumbUrl || url || '' +} + +function formatMetaDisplay(value) { + const normalized = String(value || '').trim() + if (!normalized) return '' + + return normalized + .replace(/[_-]+/g, ' ') + .replace(/\b\w/g, (character) => character.toUpperCase()) +} + +function StatPill({ label, value, icon, accentClassName = 'border-white/10 bg-white/[0.04] text-slate-300', valueClassName = 'text-white' }) { return ( -
-

{label}

-

{value}

+
+
+
+

{label}

+

{value}

+
+ {icon ? ( + + ) : null} +
+
+ ) +} + +function PromptHeaderStat({ label, value, icon, accentClassName = 'border-white/10 bg-white/[0.04] text-slate-300', valueClassName = 'text-white' }) { + return ( +
+
+
+

{label}

+

{value}

+
+ {icon ? ( + + ) : null} +
) } @@ -100,13 +178,17 @@ function LessonNavCard({ direction, lesson }) { ) } -function LockedPanel({ pricingUrl, label }) { +function LockedPanel({ pricingUrl, label, accessLevel, onUpgrade }) { + const isPrompt = label === 'prompt' + const requirement = promptRequirementText(accessLevel) + return (

Premium content

-

Unlock the full {label}.

-

This preview is visible, but the full Academy content stays server-side until your account has the required Creator or Pro access.

- See Academy plans +

{isPrompt ? promptUnlockHeading(accessLevel) : `Unlock the full ${label}.`}

+

{isPrompt ? promptUnlockDescription(accessLevel) : 'This preview is visible, but the full Academy content stays server-side until your account has the required access.'}

+ {requirement ?

{requirement}

: null} + See Academy plans
) } @@ -139,7 +221,7 @@ function copyTextToClipboard(text) { return Promise.reject(new Error('Clipboard unavailable')) } -function PromptCopyButton({ prompt, label = 'Copy prompt' }) { +function PromptCopyButton({ prompt, label = 'Copy prompt', analytics = null, contentId = null, eventType = 'academy_prompt_copy', metadata = {} }) { const [status, setStatus] = useState('idle') const resetTimerRef = useRef(0) @@ -148,7 +230,14 @@ function PromptCopyButton({ prompt, label = 'Copy prompt' }) { type="button" onClick={() => { copyTextToClipboard(prompt) - .then(() => setStatus('copied')) + .then(() => { + setStatus('copied') + void trackAcademyEvent(eventType, analytics?.contentType || null, contentId || analytics?.contentId || null, metadata, { + url: analytics?.eventUrl, + pageName: analytics?.pageName, + useBeacon: false, + }) + }) .catch(() => setStatus('failed')) .finally(() => { window.clearTimeout(resetTimerRef.current) @@ -243,10 +332,12 @@ function ImageLightbox({ gallery, onClose, onNavigate }) { function PromptToolNoteCard({ note, index, galleryIndex, onOpenImage }) { if (!note || typeof note !== 'object') return null - const title = note.model_name || note.provider || `Comparison ${String(index + 1).padStart(2, '0')}` + const displayType = String(note.display_type || '').trim() + const eyebrowLabel = displayType || 'AI comparison' + const title = note.model_name || note.provider || `${displayType || 'Comparison'} ${String(index + 1).padStart(2, '0')}` const subtitle = [note.provider, note.model_name].filter(Boolean).join(' · ') - const previewUrl = note.image_url || note.thumb_url || '' - const hasContent = Boolean(note.notes || note.strengths || note.weaknesses || note.best_for || note.settings || previewUrl || note.score || subtitle) + const previewUrl = promptInlineImage(note.image_url, note.thumb_url) + const hasContent = Boolean(displayType || note.notes || note.strengths || note.weaknesses || note.best_for || note.settings || previewUrl || note.score || subtitle) if (!hasContent) return null @@ -260,7 +351,7 @@ function PromptToolNoteCard({ note, index, galleryIndex, onOpenImage }) { aria-label={`Open comparison image for ${title}`} >
- {title} + {title}
Click to zoom @@ -273,7 +364,7 @@ function PromptToolNoteCard({ note, index, galleryIndex, onOpenImage }) {
-

AI comparison

+

{eyebrowLabel}

{title}

{subtitle ?

{subtitle}

: null}
@@ -325,6 +416,382 @@ function PromptToolNoteCard({ note, index, galleryIndex, onOpenImage }) { ) } +function normalizePromptDocumentation(documentation) { + const source = documentation && typeof documentation === 'object' && !Array.isArray(documentation) ? documentation : {} + const list = (key) => (Array.isArray(source[key]) ? source[key] : []) + .map((item) => String(item || '').trim()) + .filter(Boolean) + + return { + summary: String(source.summary || '').trim(), + best_for: list('best_for'), + how_to_use: list('how_to_use'), + required_inputs: list('required_inputs'), + workflow: list('workflow'), + tips: list('tips'), + common_mistakes: list('common_mistakes'), + data_accuracy_notes: list('data_accuracy_notes'), + display_notes: String(source.display_notes || '').trim(), + } +} + +function PromptDocumentationPanel({ documentation }) { + const hasContent = Boolean( + documentation.summary + || documentation.display_notes + || documentation.best_for.length + || documentation.how_to_use.length + || documentation.required_inputs.length + || documentation.workflow.length + || documentation.tips.length + || documentation.common_mistakes.length + || documentation.data_accuracy_notes.length, + ) + + if (!hasContent) return null + + return ( +
+
+

How to use

+

Prompt documentation

+ {documentation.summary ?

{documentation.summary}

: null} +
+ + {documentation.best_for.length ? ( +
+

Best for

+
+ {documentation.best_for.map((item) => ( + {item} + ))} +
+
+ ) : null} + +
+ {documentation.how_to_use.length ? ( +
+

How to use

+
    + {documentation.how_to_use.map((step, index) => ( +
  1. + {index + 1} + {step} +
  2. + ))} +
+
+ ) : null} + + {documentation.workflow.length ? ( +
+

Workflow

+
    + {documentation.workflow.map((step, index) => ( +
  1. + {index + 1} + {step} +
  2. + ))} +
+
+ ) : null} +
+ +
+ {documentation.required_inputs.length ? ( +
+

Required inputs

+
    + {documentation.required_inputs.map((item) =>
  • {item}
  • )} +
+
+ ) : null} + + {documentation.tips.length ? ( +
+

Tips

+
    + {documentation.tips.map((item) =>
  • {item}
  • )} +
+
+ ) : null} + + {documentation.common_mistakes.length ? ( +
+

Common mistakes

+
    + {documentation.common_mistakes.map((item) =>
  • {item}
  • )} +
+
+ ) : null} +
+ + {documentation.data_accuracy_notes.length ? ( +
+

Data accuracy notes

+
    + {documentation.data_accuracy_notes.map((item) =>
  • {item}
  • )} +
+
+ ) : null} + + {documentation.display_notes ? ( +
+

Display note

+

{documentation.display_notes}

+
+ ) : null} +
+ ) +} + +function PromptPlaceholderCard({ placeholder }) { + if (!placeholder || typeof placeholder !== 'object') return null + + const example = placeholder.example + const defaultValue = placeholder.default + const renderValue = (value) => { + if (value == null || value === '') return null + + if (typeof value === 'object') { + return
{JSON.stringify(value, null, 2)}
+ } + + return

{String(value)}

+ } + + return ( +
+
+
+

Placeholder

+ [{placeholder.key || 'VALUE'}] + {placeholder.label ?

{placeholder.label}

: null} +
+
+ {placeholder.type ? {placeholder.type} : null} + {placeholder.required ? Required : null} +
+
+ + {placeholder.description ?

{placeholder.description}

: null} + + {example != null && example !== '' ? ( +
+

Example

+ {renderValue(example)} +
+ ) : null} + + {defaultValue != null && defaultValue !== '' ? ( +
+

Default

+ {renderValue(defaultValue)} +
+ ) : null} +
+ ) +} + +function PromptHelperPromptCard({ helperPrompt, analytics, contentId }) { + if (!helperPrompt || typeof helperPrompt !== 'object') return null + + return ( +
+ +
+

Helper prompt

+

{helperPrompt.title || 'Helper prompt'}

+ {helperPrompt.description ?

{helperPrompt.description}

: null} +
+
+ {helperPrompt.type ? {formatMetaDisplay(helperPrompt.type)} : null} + {helperPrompt.expected_output ? {helperPrompt.expected_output} : null} +
+
+ +
+
+

Prompt text

+ +
+
{helperPrompt.prompt}
+
+
+ ) +} + +function PromptVariantCard({ variant, analytics, contentId }) { + if (!variant || typeof variant !== 'object') return null + + return ( +
+
+
+

Prompt variant

+

{variant.title || 'Variant'}

+ {variant.description ?

{variant.description}

: null} +
+
+ {variant.recommended ? Recommended : null} + {variant.slug ? {variant.slug} : null} +
+
+ + {variant.recommended_for?.length ? ( +
+ {variant.recommended_for.map((item) => ( + {item} + ))} +
+ ) : null} + +
+
+

Variant prompt

+ +
+
{variant.prompt}
+
+ + {variant.negative_prompt ? ( +
+
+

Negative prompt

+ +
+
{variant.negative_prompt}
+
+ ) : null} + + {variant.risk_notes?.length ? ( +
+

Risk notes

+
    + {variant.risk_notes.map((item) =>
  • {item}
  • )} +
+
+ ) : null} +
+ ) +} + +function PromptVariantsSection({ variants, analytics, contentId }) { + const visibleVariants = Array.isArray(variants) ? variants.filter((variant) => variant && typeof variant === 'object') : [] + const [activeVariantKey, setActiveVariantKey] = useState('') + + useEffect(() => { + if (!visibleVariants.length) { + setActiveVariantKey('') + return + } + + const recommendedVariant = visibleVariants.find((variant) => variant?.recommended) + const nextDefaultKey = String(recommendedVariant?.slug || recommendedVariant?.title || visibleVariants[0]?.slug || visibleVariants[0]?.title || 'variant-0') + + setActiveVariantKey((current) => { + if (visibleVariants.some((variant, index) => String(variant?.slug || variant?.title || `variant-${index}`) === current)) { + return current + } + + return nextDefaultKey + }) + }, [visibleVariants]) + + if (!visibleVariants.length) return null + + const activeVariant = visibleVariants.find((variant, index) => String(variant?.slug || variant?.title || `variant-${index}`) === activeVariantKey) || visibleVariants[0] + + return ( +
+
+

Variants

+

Alternative prompt versions

+

Switch between safer, shorter, or more specialized prompt variants without losing the core creative direction.

+
+ +
+
+ {visibleVariants.map((variant, index) => { + const variantKey = String(variant?.slug || variant?.title || `variant-${index}`) + const isActive = activeVariant === variant + + return ( + + ) + })} +
+
+ +
+ +
+
+ ) +} + +function PromptPublicExampleCard({ example, index, galleryIndex, onOpenImage, className = '', frameClassName }) { + if (!example || typeof example !== 'object') return null + + const previewUrl = promptInlineImage(example.image_url, example.thumb_url) + if (!previewUrl) return null + + const title = example.title || `Prompt Example ${index + 1}` + const subtitle = [example.provider, example.model_name].filter(Boolean).join(' · ') + const resolvedFrameClassName = frameClassName || (index === 0 ? 'aspect-[6/5]' : 'aspect-[4/5]') + + return ( +
+ +
+ ) +} + function AiComparisonSection({ block }) { const payload = block?.payload || {} const criteria = Array.isArray(payload.criteria) ? payload.criteria.filter(Boolean) : [] @@ -440,10 +907,14 @@ function AiComparisonSection({ block }) { ) } -export default function AcademyShow({ pageType, item, relatedLessons = [], relatedCourses = [], previousLesson = null, nextLesson = null, seo, pricingUrl, completeUrl, completed: initialCompleted, saveUrl, unsaveUrl, saved: initialSaved, submitUrl, courseContext = null }) { +export default function AcademyShow({ pageType, item, relatedLessons = [], relatedCourses = [], previousLesson = null, nextLesson = null, seo, pricingUrl, completeUrl, completed: initialCompleted, saveUrl, unsaveUrl, saved: initialSaved, submitUrl, courseContext = null, interaction = null, interactionRoutes = null, loginUrl = null, analytics = null, progressRoutes = null }) { const flash = usePage().props.flash || {} + useAcademyPageAnalytics(analytics) const [completed, setCompleted] = useState(Boolean(initialCompleted)) const [saved, setSaved] = useState(Boolean(initialSaved)) + const [liked, setLiked] = useState(Boolean(interaction?.liked)) + const [likesCount, setLikesCount] = useState(Number(interaction?.likes_count || 0)) + const [savesCount, setSavesCount] = useState(Number(interaction?.saves_count || 0)) const [tableOfContents, setTableOfContents] = useState([]) const [activeHeadingId, setActiveHeadingId] = useState('') const [lightboxGallery, setLightboxGallery] = useState(null) @@ -463,9 +934,57 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat const lessonSummary = item.excerpt || item.description || item.prompt_preview || item.content_preview || 'A focused Academy lesson with practical guidance and examples.' const lessonTags = Array.isArray(item?.tags) ? item.tags.filter(Boolean) : [] const promptPreviewImage = item?.preview_image || '' + const promptPreviewThumbImage = item?.preview_image_thumb || promptPreviewImage + const promptPreviewSrcSet = item?.preview_image_srcset || '' const promptBody = item?.prompt || item?.prompt_preview || '' + const promptDocumentation = normalizePromptDocumentation(item?.documentation) + const promptPlaceholders = Array.isArray(item?.placeholders) + ? item.placeholders.filter((placeholder) => placeholder && typeof placeholder === 'object' && [ + placeholder.key, + placeholder.label, + placeholder.description, + placeholder.example, + placeholder.default, + placeholder.type, + ].some((value) => value != null && value !== '' && value !== false)) + : [] + const promptHelperPrompts = Array.isArray(item?.helper_prompts) + ? item.helper_prompts.filter((helperPrompt) => helperPrompt && typeof helperPrompt === 'object' && [ + helperPrompt.title, + helperPrompt.description, + helperPrompt.prompt, + helperPrompt.expected_output, + helperPrompt.type, + ].some(Boolean)) + : [] + const promptVariants = Array.isArray(item?.prompt_variants) + ? item.prompt_variants.filter((variant) => variant && typeof variant === 'object' && [ + variant.title, + variant.description, + variant.prompt, + variant.negative_prompt, + variant.slug, + variant.recommended, + ...(Array.isArray(variant.recommended_for) ? variant.recommended_for : []), + ...(Array.isArray(variant.risk_notes) ? variant.risk_notes : []), + ].some((value) => value != null && value !== '' && value !== false)) + : [] + const promptPublicExamples = Array.isArray(item?.public_examples) + ? item.public_examples.filter((example) => example && typeof example === 'object' && [ + example.title, + example.caption, + example.image_path, + example.image_url, + example.thumb_path, + example.thumb_url, + example.provider, + example.model_name, + example.score, + ].some(Boolean)) + : [] const promptComparisons = Array.isArray(item?.tool_notes) ? item.tool_notes.filter((note) => note && typeof note === 'object' && note.active !== false && [ + note.display_type, note.provider, note.model_name, note.notes, @@ -483,7 +1002,30 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat const promptUsageNotes = String(item?.usage_notes || '').trim() const promptWorkflowNotes = String(item?.workflow_notes || '').trim() const promptHasFullAccess = Boolean(item?.prompt) - const promptModelsCovered = promptComparisons.map((note, index) => note.model_name || note.provider || `Model ${index + 1}`) + const hasPromptDocumentation = Boolean( + promptDocumentation.summary + || promptDocumentation.display_notes + || promptDocumentation.best_for.length + || promptDocumentation.how_to_use.length + || promptDocumentation.required_inputs.length + || promptDocumentation.workflow.length + || promptDocumentation.tips.length + || promptDocumentation.common_mistakes.length + || promptDocumentation.data_accuracy_notes.length, + ) + const hasPromptPlaceholders = Boolean(item?.has_placeholder_inputs) && promptPlaceholders.length > 0 + const promptHasLockedHelperPrompts = Boolean(item?.has_helper_prompts) && !promptHasFullAccess + const promptHasLockedVariants = Boolean(item?.has_prompt_variants) && !promptHasFullAccess + const hasPromptHelperPrompts = promptHelperPrompts.length > 0 + const hasPromptVariants = promptVariants.length > 0 + const showPromptHelperPrompts = false + const promptAccessRequirement = item?.access_requirement || promptRequirementText(item?.access_level) + const promptUnlockTitle = item?.unlock_heading || promptUnlockHeading(item?.access_level) + const promptUnlockDetails = item?.unlock_description || promptUnlockDescription(item?.access_level) + const promptFeaturedExamples = promptPreviewImage ? promptPublicExamples.slice(0, 2) : promptPublicExamples.slice(0, 4) + const promptOverflowExamples = promptPublicExamples.slice(promptFeaturedExamples.length) + const promptModelsCovered = (promptHasFullAccess && promptComparisons.length ? promptComparisons : promptPublicExamples) + .map((entry, index) => entry.model_name || entry.provider || entry.title || `Model ${index + 1}`) const promptComparisonGalleryImages = promptComparisons .map((note, index) => { const src = note.image_url || note.thumb_url || '' @@ -495,6 +1037,24 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat } }) .filter(Boolean) + const promptPublicExampleGalleryImages = [ + ...(promptPreviewImage ? [{ src: promptPreviewImage, alt: item?.title || 'Prompt preview' }] : []), + ...promptPublicExamples + .map((example, index) => { + const src = example.image_url || example.thumb_url || '' + if (!src) return null + + return { + src, + alt: example.alt || example.title || `Prompt example ${index + 1}`, + } + }) + .filter(Boolean), + ] + const promptBestUseCase = promptComparisons[0]?.best_for + || promptDocumentation.best_for[0] + || promptUsageNotes + || lessonSummary const academyBreadcrumbs = pageType === 'prompt' ? [ { label: 'Academy', href: '/academy' }, @@ -528,8 +1088,55 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat }) } - const toggleSave = () => { + const requireLogin = () => { + if (loginUrl && typeof window !== 'undefined') { + window.location.href = loginUrl + } + } + + const toggleLike = async () => { + if (!interactionRoutes?.like || !analytics?.contentType || !analytics?.contentId) { + return + } + + if (analytics?.isGuest) { + requireLogin() + return + } + + const payload = await postAcademyAction(interactionRoutes.like, { + content_type: analytics.contentType, + content_id: analytics.contentId, + }) + + if (payload?.liked !== undefined) { + setLiked(Boolean(payload.liked)) + setLikesCount(Number(payload.likes_count || 0)) + } + } + + const toggleSave = async () => { + if (interactionRoutes?.save && analytics?.contentType && analytics?.contentId) { + if (analytics?.isGuest) { + requireLogin() + return + } + + const payload = await postAcademyAction(interactionRoutes.save, { + content_type: analytics.contentType, + content_id: analytics.contentId, + }) + + if (payload?.saved !== undefined) { + setSaved(Boolean(payload.saved)) + setSavesCount(Number(payload.saves_count || 0)) + } + + return + } + const url = saved ? unsaveUrl : saveUrl + if (!url) return const method = saved ? router.delete : router.post method(url, {}, { preserveScroll: true, @@ -537,6 +1144,23 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat }) } + useEffect(() => { + if (pageType !== 'lesson' || !progressRoutes?.startLesson || !item?.id || analytics?.isGuest || completed || typeof window === 'undefined') { + return + } + + const onceKey = `academy-start-lesson:${item.id}:${courseContext?.id || 'solo'}` + if (window.sessionStorage.getItem(onceKey)) { + return + } + + window.sessionStorage.setItem(onceKey, '1') + void postAcademyAction(progressRoutes.startLesson, { + lesson_id: item.id, + course_id: courseContext?.id || null, + }) + }, [analytics?.isGuest, completed, courseContext?.id, item?.id, pageType, progressRoutes?.startLesson]) + const decreaseFontSize = () => { setLessonFontScale((current) => Math.max(fontScaleMin, Number((current - fontScaleStep).toFixed(2)))) } @@ -563,6 +1187,15 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat }) } + const openPromptExampleGallery = (index) => { + if (!promptPublicExampleGalleryImages.length) return + + setLightboxGallery({ + images: promptPublicExampleGalleryImages, + index: Math.max(0, Math.min(promptPublicExampleGalleryImages.length - 1, Number(index || 0))), + }) + } + const navigateLightboxGallery = (direction) => { setLightboxGallery((current) => { if (!current?.images?.length) return current @@ -834,7 +1467,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat {flash.success ?
{flash.success}
: null} {flash.error ?
{flash.error}
: null} - {item.locked ? : null} + {item.locked ? trackUpgradeClick(analytics, { source: `${pageType}_locked_panel` })} /> : null} {pageType === 'lesson' ? (
@@ -873,7 +1506,8 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
{completeUrl ? : null} - {saveUrl ? : null} + + {submitUrl ? Submit artwork : null}
@@ -1098,8 +1732,8 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat ) : pageType === 'prompt' ? (
-
-
+
+
@@ -1110,13 +1744,13 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat : null} - {promptBody ? : null} - {item.negative_prompt ? : null} +
+ + + {promptHasFullAccess ? : null} + {promptHasFullAccess && item.negative_prompt ? : null}
-
- - - - +
+ + + +
{lessonTags.length ? ( -
+

Microtags

-
+
{lessonTags.map((tag) => ( {tag} ))} @@ -1187,18 +1846,18 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
) : null} -
-
+
+

Prompt status

{item.locked - ? 'This page shows the prompt summary, but the full prompt text and editor notes stay locked until your Academy access level matches the template.' + ? `${promptAccessRequirement ? `${promptAccessRequirement} ` : ''}This page shows the prompt summary and public example results, but the reusable prompt system stays locked until your Academy access level matches the template.` : 'This template includes the main prompt, reuse guidance, and model-specific comparison notes in one place.'}

{promptModelsCovered.length ? ( -
+

Compared with

@@ -1221,6 +1880,71 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
+ {!promptHasFullAccess && (promptPreviewImage || promptPublicExamples.length) ? ( +
+
+
+

Public examples

+

Example results from this prompt

+

Preview the visual direction before unlocking the full prompt.

+
+ {item.locked && promptAccessRequirement ? {promptAccessRequirement} : null} +
+ +
+ {promptPreviewImage ? ( + + ) : null} + + {promptFeaturedExamples.length ? ( +
+ {promptFeaturedExamples.map((example, index) => ( + + ))} +
+ ) : null} +
+ + {promptOverflowExamples.length ? ( +
+ {promptOverflowExamples.map((example, index) => ( + + ))} +
+ ) : null} +
+ ) : null} +
@@ -1230,19 +1954,46 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
-
+

{promptHasFullAccess ? 'Full prompt' : 'Preview prompt'}

{promptHasFullAccess ? 'Ready to paste into your generation workflow.' : 'Upgrade your Academy access to reveal the complete prompt text.'}

+ {promptBody ? ( + + ) : null}
{promptBody || 'Prompt text is not available yet.'}
+ {!promptHasFullAccess ? ( +
+

{promptUnlockTitle || 'Unlock the full prompt'}

+

{promptUnlockDetails}

+ {promptAccessRequirement ?

{promptAccessRequirement}

: null} +
+ ) : null}
{item.negative_prompt ? (
-

Negative prompt

+
+

Negative prompt

+ +
{item.negative_prompt}
) : null} @@ -1250,7 +2001,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
{(promptUsageNotes || promptWorkflowNotes) ? ( -
+

Prompt guidance

@@ -1277,8 +2028,70 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
) : null} + {hasPromptDocumentation ? : null} + + {hasPromptPlaceholders ? ( +
+
+

Data

+

Placeholders and required inputs

+

Prepare these variables before using the final prompt so the output stays consistent and reusable.

+
+ +
+ {promptPlaceholders.map((placeholder, index) => ( + + ))} +
+
+ ) : null} + + {showPromptHelperPrompts && hasPromptHelperPrompts ? ( +
+
+

Data helpers

+

Helper prompts for preparation and validation

+

Use these supporting prompts before or after the main prompt when you need better source data, cleaner structure, or a validation pass.

+
+ +
+ {promptHelperPrompts.map((helperPrompt, index) => ( + + ))} +
+
+ ) : null} + + {showPromptHelperPrompts && promptHasLockedHelperPrompts ? ( +
+
+

Data helpers

+

Helper prompts are included with this template

+

Data collection, validation, or refinement prompts are available once your Academy access matches this template.

+
+
+ trackUpgradeClick(analytics, { source: 'prompt_helper_locked_panel' })} /> +
+
+ ) : null} + + {hasPromptVariants ? : null} + + {promptHasLockedVariants ? ( +
+
+

Variants

+

Alternative prompt versions are included

+

This prompt includes recommended or model-specific variants, but they stay locked until your Academy access level matches the template.

+
+
+ trackUpgradeClick(analytics, { source: 'prompt_variant_locked_panel' })} /> +
+
+ ) : null} + {promptComparisons.length ? ( -
+

AI model comparisons

How different models respond to the same prompt

@@ -1306,7 +2119,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat

Best use case

-

{promptComparisons[0]?.best_for || promptUsageNotes || lessonSummary}

+

{promptBestUseCase}

diff --git a/resources/js/Pages/Admin/Academy/AnalyticsContent.jsx b/resources/js/Pages/Admin/Academy/AnalyticsContent.jsx new file mode 100644 index 00000000..75bb26ac --- /dev/null +++ b/resources/js/Pages/Admin/Academy/AnalyticsContent.jsx @@ -0,0 +1,77 @@ +import React from 'react' +import { Head } from '@inertiajs/react' +import AdminLayout from '../../../Layouts/AdminLayout' +import AnalyticsNav from './AnalyticsNav' + +function MetricCell({ value, suffix = '' }) { + return {value}{suffix} +} + +export default function AcademyAnalyticsContent({ nav = [], range, title, subtitle, rows = [] }) { + return ( + + + +
+ + +
+

Range

+

{range?.from} to {range?.to}

+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + {rows.length ? rows.map((row) => ( + + + + + + + + + + + + + + + + + )) : ( + + + + )} + +
TitleTypeAccessViewsUniqueEngagedLikesSavesCopiesStartsCompletionsUpgrade ClicksPopularityTrend
+

{row.title}

+

ID {row.content_id || 'n/a'}

+
{row.content_type_label}{row.access_level || 'n/a'}{row.trend}
No rollup data available yet for this view.
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/resources/js/Pages/Admin/Academy/AnalyticsFunnel.jsx b/resources/js/Pages/Admin/Academy/AnalyticsFunnel.jsx new file mode 100644 index 00000000..c043ea3b --- /dev/null +++ b/resources/js/Pages/Admin/Academy/AnalyticsFunnel.jsx @@ -0,0 +1,60 @@ +import React from 'react' +import { Head } from '@inertiajs/react' +import AdminLayout from '../../../Layouts/AdminLayout' +import AnalyticsNav from './AnalyticsNav' + +function StatCard({ label, value }) { + return ( +
+

{label}

+

{Number(value || 0).toLocaleString()}

+
+ ) +} + +export default function AcademyAnalyticsFunnel({ nav = [], range, summary = {}, bestConverters = [] }) { + return ( + + + +
+ + +
+

Range

+

{range?.from} to {range?.to}

+
+ +
+ + + + + + + +
+ +
+

Best Converting Content

+
+ {bestConverters.length ? bestConverters.map((item) => ( +
+
+
+

{item.title}

+

{item.content_type_label}

+
+
+

{item.conversion_score}

+

conversion

+
+
+
+ )) :

No conversion signals have been rolled up yet.

} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/resources/js/Pages/Admin/Academy/AnalyticsIntelligence.jsx b/resources/js/Pages/Admin/Academy/AnalyticsIntelligence.jsx new file mode 100644 index 00000000..16b2777a --- /dev/null +++ b/resources/js/Pages/Admin/Academy/AnalyticsIntelligence.jsx @@ -0,0 +1,307 @@ +import React, { useState } from 'react' +import { Head, router } from '@inertiajs/react' +import AdminLayout from '../../../Layouts/AdminLayout' +import AnalyticsNav from './AnalyticsNav' + +function SummaryCard({ label, value, description }) { + return ( +
+

{label}

+

{Number(value || 0).toLocaleString()}

+

{description}

+
+ ) +} + +function RangeControls({ range }) { + const pathname = typeof window !== 'undefined' ? window.location.pathname : '' + const [from, setFrom] = useState(range?.from || '') + const [to, setTo] = useState(range?.to || '') + + const visit = (nextRange, nextFrom = from, nextTo = to) => { + router.get(pathname, { + range: nextRange, + ...(nextRange === 'custom' ? { from: nextFrom, to: nextTo } : {}), + }, { + preserveScroll: true, + preserveState: true, + replace: true, + }) + } + + return ( +
+
+
+

Date Range

+

{range?.from} to {range?.to}

+
+
+ {(range?.options || []).map((option) => { + const active = option.value === range?.active + + return ( + + ) + })} +
+
+ +
+ + + +
+
+ ) +} + +function Section({ title, description, children }) { + return ( +
+
+
+

{title}

+

{description}

+
+
+
{children}
+
+ ) +} + +function EmptyState({ text }) { + return
{text}
+} + +function Badge({ children, tone = 'default' }) { + const tones = { + default: 'border-white/[0.08] bg-white/[0.04] text-slate-200', + high: 'border-rose-300/25 bg-rose-300/10 text-rose-100', + medium: 'border-amber-300/25 bg-amber-300/10 text-amber-100', + low: 'border-emerald-300/25 bg-emerald-300/10 text-emerald-100', + } + + return {children} +} + +function Table({ columns, children }) { + return ( +
+ + + + {columns.map((column) => ( + + ))} + + + {children} +
{column}
+
+ ) +} + +function OpportunityHighlights({ items = [] }) { + if (!items.length) { + return + } + + return ( +
+ {items.map((item, index) => ( +
+
+

{item.title}

+ {item.priority} +
+

{item.reason}

+

{item.suggested_action}

+
+ ))} +
+ ) +} + +export default function AcademyAnalyticsIntelligence({ + nav = [], + range, + contentOpportunities = {}, + searchGaps = {}, + promptInsights = {}, + lessonDropoffs = {}, + courseHealth = {}, + premiumInterest = {}, + editorialRecommendations = {}, +}) { + return ( + + + +
+ + + +
+
+ {(contentOpportunities?.cards || []).map((card) => ( + + ))} +
+
+ +
+
+ +
+ {searchGaps?.rows?.length ? ( + + {searchGaps.rows.map((row) => ( + + + + + + + + + ))} +
+

{row.query}

+
+ {row.issue} + {row.logged_in_searches > 1 ? Logged-in x{row.logged_in_searches} : null} + {row.subscriber_searches > 0 ? Subscribers x{row.subscriber_searches} : null} +
+
{row.searches}{row.results_count}{row.clicks}{row.ctr}%{row.suggested_action}
+ ) : } +
+ +
+ {promptInsights?.rows?.length ? ( + + {promptInsights.rows.map((row) => ( + + + + + + + + + + + ))} +
+

{row.title}

+

{row.content_type_label}

+
{row.views}{row.prompt_copies}{row.copy_rate}%{row.saves}{row.likes}{row.issue}{row.suggested_action}
+ ) : } +
+ +
+ {lessonDropoffs?.rows?.length ? ( + + {lessonDropoffs.rows.map((row) => ( + + + + + + + + + + ))} +
+

{row.title}

+

Start rate {row.start_rate}%

+
{row.views}{row.starts}{row.completions}{row.completion_rate}%{row.issue}{row.suggested_action}
+ ) : } +
+ +
+ {courseHealth?.rows?.length ? ( + + {courseHealth.rows.map((row) => ( + + + + + + + + + + ))} +
+

{row.title}

+
+ {row.issue} + {row.learners > 0 ? Learners {row.learners} : null} +
+
{row.views}{row.starts}{row.completions}{row.completion_rate}%{row.avg_progress}%{row.suggested_action}
+ ) : } +
+ +
+ {premiumInterest?.rows?.length ? ( + + {premiumInterest.rows.map((row) => ( + + + + + + + + + ))} +
+

{row.title}

+
+ {row.issue} + Interest score {row.premium_interest_score} +
+
{row.content_type_label}{row.premium_preview_views}{row.upgrade_clicks}{row.upgrade_rate}%{row.suggested_action}
+ ) : } +
+ +
+ {editorialRecommendations?.rows?.length ? ( +
+ {editorialRecommendations.rows.map((row, index) => ( +
+
+

{row.title}

+ {row.priority} +
+

{row.description}

+

Reason

+

{row.reason}

+

Suggested Action

+

{row.suggested_action}

+
+ ))} +
+ ) : } +
+
+
+ ) +} \ No newline at end of file diff --git a/resources/js/Pages/Admin/Academy/AnalyticsNav.jsx b/resources/js/Pages/Admin/Academy/AnalyticsNav.jsx new file mode 100644 index 00000000..4b7ba358 --- /dev/null +++ b/resources/js/Pages/Admin/Academy/AnalyticsNav.jsx @@ -0,0 +1,26 @@ +import React from 'react' +import { Link } from '@inertiajs/react' + +export default function AnalyticsNav({ items = [] }) { + if (!items.length) return null + + const pathname = typeof window !== 'undefined' ? window.location.pathname : '' + + return ( +
+ {items.map((item) => { + const active = pathname === item.href + + return ( + + {item.label} + + ) + })} +
+ ) +} \ No newline at end of file diff --git a/resources/js/Pages/Admin/Academy/AnalyticsOverview.jsx b/resources/js/Pages/Admin/Academy/AnalyticsOverview.jsx new file mode 100644 index 00000000..60239002 --- /dev/null +++ b/resources/js/Pages/Admin/Academy/AnalyticsOverview.jsx @@ -0,0 +1,73 @@ +import React from 'react' +import { Head } from '@inertiajs/react' +import AdminLayout from '../../../Layouts/AdminLayout' +import AnalyticsNav from './AnalyticsNav' + +function StatCard({ label, value }) { + return ( +
+

{label}

+

{Number(value || 0).toLocaleString()}

+
+ ) +} + +function ContentList({ title, items = [] }) { + return ( +
+

{title}

+
+ {items.length ? items.map((item) => ( +
+
+
+

{item.title}

+

{item.content_type_label}

+
+
+

{item.popularity_score}

+

popularity

+
+
+
+ )) :

No rollup data yet for this range.

} +
+
+ ) +} + +export default function AcademyAnalyticsOverview({ nav = [], range, stats, topContent = [], topWeek = [] }) { + return ( + + + +
+ + +
+

Range

+

{range?.from} to {range?.to}

+
+ +
+ + + + + + + + + + + +
+ +
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/resources/js/Pages/Admin/Academy/AnalyticsSearch.jsx b/resources/js/Pages/Admin/Academy/AnalyticsSearch.jsx new file mode 100644 index 00000000..0be3a11a --- /dev/null +++ b/resources/js/Pages/Admin/Academy/AnalyticsSearch.jsx @@ -0,0 +1,109 @@ +import React from 'react' +import { Head } from '@inertiajs/react' +import AdminLayout from '../../../Layouts/AdminLayout' +import AnalyticsNav from './AnalyticsNav' + +function SearchList({ title, items = [], emptyText }) { + return ( +
+

{title}

+
+ {items.length ? items.map((item, index) => ( +
+
+

{item.query}

+ {'searches' in item ?

{item.searches}

: null} +
+ {'avg_results' in item ?

Average results: {item.avg_results}

: null} + {'clicks' in item ?

Clicks: {item.clicks}

: null} + {'click_through_rate' in item ?

CTR: {item.click_through_rate}%

: null} + {'results_count' in item ?

Results: {item.results_count}

: null} +
+ )) :

{emptyText}

} +
+
+ ) +} + +function FilterUsageList({ items = [] }) { + return ( +
+

Filter Usage

+
+ {items.length ? items.map((item) => ( +
+
+

{item.filter}: {item.value}

+

{item.uses}

+
+
+ )) :

No Academy search filters were used in this range.

} +
+
+ ) +} + +function ClickedResultsList({ items = [] }) { + return ( +
+

Top Clicked Results

+
+ {items.length ? items.map((item) => ( +
+
+

{item.title}

+

{item.clicks}

+
+

{item.content_type}

+
+ )) :

No clicked Academy search results were logged in this range.

} +
+
+ ) +} + +export default function AcademyAnalyticsSearch({ nav = [], range, summary = {}, topSearches = [], zeroResults = [], lowClickThroughSearches = [], highestClickThroughSearches = [], searchesWithResultsNoClicks = [], topClickedResults = [], filterUsage = [], recentSearches = [] }) { + return ( + + + +
+ + +
+

Searches

{Number(summary.searches || 0).toLocaleString()}

+

Zero Result Searches

{Number(summary.zeroResultSearches || 0).toLocaleString()}

+

Logged-in Searches

{Number(summary.loggedInSearches || 0).toLocaleString()}

+

Subscriber Searches

{Number(summary.subscriberSearches || 0).toLocaleString()}

+
+ +
+

Searches With Clicks

{Number(summary.searchesWithClicks || 0).toLocaleString()}

+

Needs CTR Tracking

Low-click sections use stored search click attribution when present. Queries without clicked-result updates will stay at 0% CTR until that interaction is sent.

+
+ +
+

Range

+

{range?.from} to {range?.to}

+
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/resources/js/Pages/Admin/Academy/Billing.jsx b/resources/js/Pages/Admin/Academy/Billing.jsx new file mode 100644 index 00000000..e87014bf --- /dev/null +++ b/resources/js/Pages/Admin/Academy/Billing.jsx @@ -0,0 +1,206 @@ +import React from 'react' +import { Head, Link } from '@inertiajs/react' +import AdminLayout from '../../../Layouts/AdminLayout' + +function StatCard({ label, value, hint = null }) { + return ( +
+

{label}

+

{value.toLocaleString()}

+ {hint ?

{hint}

: null} +
+ ) +} + +function formatTimestamp(value) { + if (!value) return 'No webhook processed yet' + + try { + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)) + } catch { + return value + } +} + +function formatEventSummary(summary) { + const payload = summary && typeof summary === 'object' ? summary : {} + const preferredKeys = [ + 'action', + 'outcome', + 'local_subscription_status', + 'status', + 'tracked', + 'user_resolved', + ] + + const prioritized = preferredKeys + .filter((key) => Object.prototype.hasOwnProperty.call(payload, key)) + .map((key) => [key, payload[key]]) + + const priceIds = Array.isArray(payload.price_ids) && payload.price_ids.length + ? [['price_ids', payload.price_ids.join(', ')]] + : [] + + const cacheCleared = typeof payload.cache_cleared === 'boolean' + ? [['cache_cleared', payload.cache_cleared ? 'yes' : 'no']] + : [] + + const lines = [...prioritized, ...priceIds, ...cacheCleared] + .filter(([, value]) => value !== null && value !== undefined && value !== '') + .slice(0, 4) + + return lines.length + ? lines.map(([key, value]) => `${key}: ${String(value)}`).join(' · ') + : 'No summary fields captured' +} + +export default function AcademyBilling({ summary, planBreakdown, recentEvents, links }) { + const missingPlans = Array.isArray(summary.missing_plan_keys) ? summary.missing_plan_keys : [] + const noData = + summary.enabled && + (summary.active_subscribers || 0) === 0 && + (summary.ended_subscriptions || 0) === 0 && + (summary.recent_webhook_count || 0) === 0 + + return ( + + + + {noData ? ( +
+

No subscriber data in the database yet.

+

+ Subscription records are created when Stripe sends webhook events to this server after a completed checkout. In local development, use{' '} + stripe listen --forward-to {window.location.origin}/stripe/webhook{' '} + to forward events. On production, confirm the Stripe webhook is configured and active. +

+
+ ) : null} + +
+ + + + +
+ +
+
+
+
+

Plan Health

+

Configured Academy plans

+
+
+ + Dashboard + + + Public pricing + + + My billing account + +
+
+ + {missingPlans.length ? ( +
+ Missing Stripe price IDs for: {missingPlans.join(', ')} +
+ ) : ( +
+ All configured Academy plans have Stripe price IDs. +
+ )} + +
+ {planBreakdown.map((plan) => ( +
+
+
+

{plan.label}

+

{plan.tier} · {plan.interval}

+
+ + {plan.configured ? 'configured' : 'missing'} + +
+

{(plan.subscribers || 0).toLocaleString()}

+

active subscriptions on this plan

+
+ ))} +
+
+ +
+

Webhook Sync

+

Recent Stripe activity

+ +
+
+

Billing enabled

+

{summary.enabled ? 'Yes' : 'No'}

+
+
+

Webhook audits stored

+

{(summary.recent_webhook_count || 0).toLocaleString()}

+
+
+

Last processed webhook

+

{formatTimestamp(summary.last_webhook_at)}

+
+
+

Ended subscriptions

+

{(summary.ended_subscriptions || 0).toLocaleString()}

+
+
+
+
+ +
+
+
+

Audit Trail

+

Latest academy billing events

+
+

Only the safe local summary is stored, not the raw Stripe payload.

+
+ +
+ + + + + + + + + + + + + {recentEvents.length ? recentEvents.map((event) => ( + + + + + + + + + )) : ( + + + + )} + +
EventPlanTierUserProcessedSummary
{event.event_type}{event.academy_plan || 'n/a'}{event.academy_tier || 'n/a'}{event.user_id || 'guest/unresolved'}{formatTimestamp(event.processed_at || event.created_at)}{formatEventSummary(event.payload_summary)}
No Academy billing webhook audits have been stored yet.
+
+
+
+ ) +} \ No newline at end of file diff --git a/resources/js/Pages/Admin/Academy/CourseEditor.jsx b/resources/js/Pages/Admin/Academy/CourseEditor.jsx index a49ff498..1e5cb14c 100644 --- a/resources/js/Pages/Admin/Academy/CourseEditor.jsx +++ b/resources/js/Pages/Admin/Academy/CourseEditor.jsx @@ -1,10 +1,12 @@ import React, { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import { Head, Link, router, useForm } from '@inertiajs/react' +import { createPortal } from 'react-dom' import AdminLayout from '../../../Layouts/AdminLayout' import RichTextEditor from '../../../components/forum/RichTextEditor' import WorldMediaUploadField from '../../../components/worlds/editor/WorldMediaUploadField' import DateTimePicker from '../../../components/ui/DateTimePicker' import NovaSelect from '../../../components/ui/NovaSelect' +import ShareToast from '../../../components/ui/ShareToast' const COURSE_EDITOR_TABS = [ { @@ -214,6 +216,182 @@ function formatLessonStep(orderNum) { return `Step ${String(numeric + 1).padStart(2, '0')}` } +function lessonActivityBadgeMeta(lesson) { + const isActive = Boolean(lesson?.active) + + return { + label: isActive ? 'Active' : 'Inactive', + className: isActive + ? 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100' + : 'border-amber-300/20 bg-amber-300/10 text-amber-200', + } +} + +function lessonPublicationBadgeMeta(lesson) { + const state = String(lesson?.publication_state || 'draft') + const label = String(lesson?.publication_label || (state === 'published' ? 'Published' : state === 'scheduled' ? 'Scheduled' : 'Unscheduled')) + + if (state === 'published') { + return { + label, + className: 'border-sky-300/20 bg-sky-300/10 text-sky-100', + } + } + + if (state === 'scheduled') { + return { + label, + className: 'border-fuchsia-300/20 bg-fuchsia-300/10 text-fuchsia-100', + } + } + + return { + label, + className: 'border-white/10 bg-white/[0.04] text-slate-400', + } +} + +function CourseSectionCreateCard({ storeUrl, nextOrderNum }) { + const form = useForm({ + title: '', + slug: '', + description: '', + order_num: nextOrderNum, + is_visible: true, + }) + + useEffect(() => { + form.setData('order_num', nextOrderNum) + }, [nextOrderNum]) + + const createSection = () => { + if (!storeUrl) return + + form.post(storeUrl, { + preserveScroll: true, + onSuccess: () => { + form.setData({ + title: '', + slug: '', + description: '', + order_num: nextOrderNum, + is_visible: true, + }) + }, + }) + } + + return ( +
+
+
+

New section

+

Create a new course section here, then assign lessons into it below.

+
+ Order {Number(nextOrderNum || 0) + 1} +
+ +
+ + +