Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render
This commit is contained in:
24
.env.example
24
.env.example
@@ -329,6 +329,30 @@ TURNSTILE_FAIL_OPEN=false
|
||||
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
|
||||
TURNSTILE_TIMEOUT=5
|
||||
|
||||
ENHANCE_DISK=public
|
||||
ENHANCE_SOURCE_PREFIX=enhance/sources
|
||||
ENHANCE_OUTPUT_PREFIX=enhance/outputs
|
||||
ENHANCE_PREVIEW_PREFIX=enhance/previews
|
||||
ENHANCE_ENGINE=stub
|
||||
ENHANCE_MAX_UPLOAD_MB=20
|
||||
ENHANCE_MAX_INPUT_WIDTH=4096
|
||||
ENHANCE_MAX_INPUT_HEIGHT=4096
|
||||
ENHANCE_MAX_OUTPUT_WIDTH=8192
|
||||
ENHANCE_MAX_OUTPUT_HEIGHT=8192
|
||||
ENHANCE_DAILY_LIMIT=10
|
||||
ENHANCE_QUEUE=default
|
||||
ENHANCE_COMPLETED_EXPIRES_AFTER_DAYS=30
|
||||
ENHANCE_FAILED_EXPIRES_AFTER_DAYS=7
|
||||
ENHANCE_DELETED_FILE_GRACE_DAYS=1
|
||||
ENHANCE_CLEANUP_CHUNK_SIZE=100
|
||||
ENHANCE_STUCK_PROCESSING_AFTER_MINUTES=30
|
||||
ENHANCE_STUCK_QUEUED_AFTER_MINUTES=60
|
||||
ENHANCE_STUB_SHOW_WARNING=true
|
||||
ENHANCE_WORKER_URL=
|
||||
ENHANCE_WORKER_TIMEOUT=300
|
||||
ENHANCE_WORKER_TOKEN=
|
||||
ENHANCE_WORKER_MAX_DOWNLOAD_MB=60
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
|
||||
290
app/Console/Commands/Enhance/CleanupEnhanceJobsCommand.php
Normal file
290
app/Console/Commands/Enhance/CleanupEnhanceJobsCommand.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Enhance;
|
||||
|
||||
use App\Models\EnhanceJob;
|
||||
use App\Services\Enhance\EnhanceStorageService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
final class CleanupEnhanceJobsCommand extends Command
|
||||
{
|
||||
protected $signature = 'enhance:cleanup
|
||||
{--dry-run : Preview cleanup actions only}
|
||||
{--force : Delete files and update records}
|
||||
{--only= : Restrict cleanup to expired, deleted, failed, or orphaned}
|
||||
{--days= : Override retention days for failed or deleted cleanup}';
|
||||
|
||||
protected $description = 'Safely clean expired, deleted, failed, and orphaned Enhance files.';
|
||||
|
||||
public function __construct(
|
||||
private readonly EnhanceStorageService $storage,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$target = strtolower(trim((string) $this->option('only')));
|
||||
$validTargets = ['', 'expired', 'deleted', 'failed', 'orphaned'];
|
||||
|
||||
if (! in_array($target, $validTargets, true)) {
|
||||
$this->error('The --only option must be one of: expired, deleted, failed, orphaned.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ((bool) $this->option('dry-run') && (bool) $this->option('force')) {
|
||||
$this->error('Use either --dry-run or --force, not both.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$dryRun = (bool) $this->option('dry-run') || ! (bool) $this->option('force');
|
||||
$daysOverride = $this->option('days');
|
||||
$selectedTarget = $target !== '' ? $target : 'all';
|
||||
|
||||
Log::info('enhance.cleanup.started', [
|
||||
'dry_run' => $dryRun,
|
||||
'target' => $selectedTarget,
|
||||
'days_override' => $daysOverride,
|
||||
]);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('Running in dry-run mode. No files will be deleted.');
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
|
||||
if ($target === '' || $target === 'expired') {
|
||||
$expired = $this->cleanupExpiredCompletedJobs($dryRun);
|
||||
$rows[] = ['expired', $expired['jobs'], $expired['files'], $dryRun ? 'dry-run' : 'cleaned'];
|
||||
}
|
||||
|
||||
if ($target === '' || $target === 'failed') {
|
||||
$failed = $this->cleanupFailedJobs($dryRun, $daysOverride);
|
||||
$rows[] = ['failed', $failed['jobs'], $failed['files'], $dryRun ? 'dry-run' : 'cleaned'];
|
||||
}
|
||||
|
||||
if ($target === '' || $target === 'deleted') {
|
||||
$deleted = $this->cleanupSoftDeletedJobs($dryRun, $daysOverride);
|
||||
$rows[] = ['deleted', $deleted['jobs'], $deleted['files'], $dryRun ? 'dry-run' : 'cleaned'];
|
||||
}
|
||||
|
||||
if ($target === 'orphaned') {
|
||||
$orphaned = $this->scanOrphanedFiles($dryRun);
|
||||
$rows[] = ['orphaned', $orphaned['files'], $orphaned['deleted'], $dryRun ? 'dry-run' : 'deleted'];
|
||||
|
||||
if ($orphaned['unsupported']) {
|
||||
$this->warn('Orphaned file scan was skipped because the configured disk does not support safe listing.');
|
||||
}
|
||||
|
||||
foreach ($orphaned['sample'] as $path) {
|
||||
$this->line(' - ' . $path);
|
||||
}
|
||||
}
|
||||
|
||||
if ($rows !== []) {
|
||||
$this->table(['Target', 'Jobs/Files', 'Files deleted', 'Mode'], $rows);
|
||||
}
|
||||
|
||||
Log::info('enhance.cleanup.completed', [
|
||||
'dry_run' => $dryRun,
|
||||
'target' => $selectedTarget,
|
||||
'rows' => $rows,
|
||||
]);
|
||||
|
||||
$this->info('Enhance cleanup finished.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function cleanupExpiredCompletedJobs(bool $dryRun): array
|
||||
{
|
||||
$query = EnhanceJob::query()
|
||||
->where('status', EnhanceJob::STATUS_COMPLETED)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now());
|
||||
|
||||
return $this->cleanupJobs($query, $dryRun, 'expired', fn (): array => [
|
||||
'status' => EnhanceJob::STATUS_EXPIRED,
|
||||
]);
|
||||
}
|
||||
|
||||
private function cleanupFailedJobs(bool $dryRun, mixed $daysOverride): array
|
||||
{
|
||||
$days = $this->resolveDays($daysOverride, (int) config('enhance.lifecycle.failed_expires_after_days', 7));
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$query = EnhanceJob::query()
|
||||
->where('status', EnhanceJob::STATUS_FAILED)
|
||||
->where(function (Builder $builder) use ($cutoff): void {
|
||||
$builder
|
||||
->where('finished_at', '<=', $cutoff)
|
||||
->orWhere(function (Builder $fallback) use ($cutoff): void {
|
||||
$fallback->whereNull('finished_at')->where('created_at', '<=', $cutoff);
|
||||
});
|
||||
});
|
||||
|
||||
return $this->cleanupJobs($query, $dryRun, 'failed-expired');
|
||||
}
|
||||
|
||||
private function cleanupSoftDeletedJobs(bool $dryRun, mixed $daysOverride): array
|
||||
{
|
||||
$days = $this->resolveDays($daysOverride, (int) config('enhance.lifecycle.deleted_file_grace_days', 1));
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$query = EnhanceJob::withTrashed()
|
||||
->whereNotNull('deleted_at')
|
||||
->where('deleted_at', '<=', $cutoff);
|
||||
|
||||
return $this->cleanupJobs($query, $dryRun, 'deleted-grace');
|
||||
}
|
||||
|
||||
private function cleanupJobs(Builder $query, bool $dryRun, string $reason, ?callable $attributes = null): array
|
||||
{
|
||||
$result = ['jobs' => 0, 'files' => 0];
|
||||
$chunkSize = max(1, (int) config('enhance.lifecycle.cleanup_chunk_size', 100));
|
||||
|
||||
$query->chunkById($chunkSize, function ($jobs) use (&$result, $dryRun, $reason, $attributes): void {
|
||||
foreach ($jobs as $job) {
|
||||
$result['jobs']++;
|
||||
$result['files'] += count($this->enhanceJobPaths($job));
|
||||
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$deleteResult = $this->storage->deleteFilesForJob($job);
|
||||
$metadata = is_array($job->metadata) ? $job->metadata : [];
|
||||
|
||||
$job->forceFill(array_merge(
|
||||
$this->cleanupAttributesForJob($job),
|
||||
$attributes ? $attributes($job) : [],
|
||||
[
|
||||
'metadata' => array_merge($metadata, [
|
||||
'cleanup' => [
|
||||
'files_removed_at' => now()->toIso8601String(),
|
||||
'reason' => $reason,
|
||||
'deleted' => $deleteResult['deleted'],
|
||||
],
|
||||
]),
|
||||
],
|
||||
))->save();
|
||||
}
|
||||
});
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function scanOrphanedFiles(bool $dryRun): array
|
||||
{
|
||||
$disk = $this->storage->diskName();
|
||||
$knownPaths = array_fill_keys(array_map(
|
||||
static fn (string $path): string => ltrim($path, '/'),
|
||||
$this->storage->listKnownJobPaths(),
|
||||
), true);
|
||||
$sample = [];
|
||||
$result = [
|
||||
'files' => 0,
|
||||
'deleted' => 0,
|
||||
'unsupported' => false,
|
||||
'sample' => [],
|
||||
];
|
||||
|
||||
foreach ($this->enhancePrefixes() as $prefix) {
|
||||
try {
|
||||
$files = Storage::disk($disk)->allFiles($prefix);
|
||||
} catch (Throwable) {
|
||||
$result['unsupported'] = true;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
$normalized = ltrim($file, '/');
|
||||
|
||||
if (isset($knownPaths[$normalized])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result['files']++;
|
||||
|
||||
if (count($sample) < 20) {
|
||||
$sample[] = $normalized;
|
||||
}
|
||||
|
||||
if (! $dryRun && $this->storage->safeDelete($disk, $normalized)) {
|
||||
$result['deleted']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result['sample'] = $sample;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function cleanupAttributesForJob(EnhanceJob $job): array
|
||||
{
|
||||
$attributes = [];
|
||||
|
||||
if ($this->storage->isEnhancePath($job->source_path)) {
|
||||
$attributes['source_disk'] = null;
|
||||
$attributes['source_path'] = null;
|
||||
$attributes['source_hash'] = null;
|
||||
}
|
||||
|
||||
if ($this->storage->isEnhancePath($job->output_path)) {
|
||||
$attributes['output_disk'] = null;
|
||||
$attributes['output_path'] = null;
|
||||
$attributes['output_hash'] = null;
|
||||
$attributes['output_width'] = null;
|
||||
$attributes['output_height'] = null;
|
||||
$attributes['output_filesize'] = null;
|
||||
$attributes['output_mime'] = null;
|
||||
}
|
||||
|
||||
if ($this->storage->isEnhancePath($job->preview_path)) {
|
||||
$attributes['preview_disk'] = null;
|
||||
$attributes['preview_path'] = null;
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
private function enhanceJobPaths(EnhanceJob $job): array
|
||||
{
|
||||
return array_values(array_filter([
|
||||
$this->storage->isEnhancePath($job->source_path) ? trim((string) $job->source_path) : null,
|
||||
$this->storage->isEnhancePath($job->output_path) ? trim((string) $job->output_path) : null,
|
||||
$this->storage->isEnhancePath($job->preview_path) ? trim((string) $job->preview_path) : null,
|
||||
]));
|
||||
}
|
||||
|
||||
private function enhancePrefixes(): array
|
||||
{
|
||||
return array_values(array_filter(array_unique(array_map(
|
||||
static fn (string $prefix): string => trim($prefix, '/'),
|
||||
[
|
||||
(string) config('enhance.source_prefix', 'enhance/sources'),
|
||||
(string) config('enhance.output_prefix', 'enhance/outputs'),
|
||||
(string) config('enhance.preview_prefix', 'enhance/previews'),
|
||||
],
|
||||
))));
|
||||
}
|
||||
|
||||
private function resolveDays(mixed $daysOverride, int $default): int
|
||||
{
|
||||
if ($daysOverride === null || $daysOverride === '') {
|
||||
return max(0, $default);
|
||||
}
|
||||
|
||||
return max(0, (int) $daysOverride);
|
||||
}
|
||||
}
|
||||
108
app/Console/Commands/Enhance/EnhanceHealthCommand.php
Normal file
108
app/Console/Commands/Enhance/EnhanceHealthCommand.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Enhance;
|
||||
|
||||
use App\Models\EnhanceJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class EnhanceHealthCommand extends Command
|
||||
{
|
||||
protected $signature = 'enhance:health {--json : Output machine-readable JSON}';
|
||||
|
||||
protected $description = 'Report operational health and lifecycle metrics for Enhance jobs.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$payload = $this->payload();
|
||||
|
||||
if ((bool) $this->option('json')) {
|
||||
$this->line(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info('Enhance health');
|
||||
$this->newLine();
|
||||
|
||||
$this->table(['Metric', 'Value'], [
|
||||
['Configured engine', $payload['engine']],
|
||||
['Configured queue', $payload['queue']],
|
||||
['Worker URL configured', $payload['worker_configured'] ? 'yes' : 'no'],
|
||||
['Storage disk', $payload['storage_disk']],
|
||||
['Total jobs', $payload['counts']['total']],
|
||||
['Pending jobs', $payload['counts']['pending']],
|
||||
['Queued jobs', $payload['counts']['queued']],
|
||||
['Processing jobs', $payload['counts']['processing']],
|
||||
['Completed jobs', $payload['counts']['completed']],
|
||||
['Failed jobs', $payload['counts']['failed']],
|
||||
['Cancelled jobs', $payload['counts']['cancelled']],
|
||||
['Expired jobs', $payload['counts']['expired']],
|
||||
['Stuck queued jobs', $payload['health']['stuck_queued']],
|
||||
['Stuck processing jobs', $payload['health']['stuck_processing']],
|
||||
['Jobs created today', $payload['today']['created']],
|
||||
['Jobs completed today', $payload['today']['completed']],
|
||||
['Jobs failed today', $payload['today']['failed']],
|
||||
['Average processing time today', $payload['today']['average_processing_seconds'] ?? '—'],
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function payload(): array
|
||||
{
|
||||
$todayStart = now()->startOfDay();
|
||||
$todayEnd = now()->endOfDay();
|
||||
$stuckQueuedCutoff = now()->subMinutes((int) config('enhance.health.stuck_queued_after_minutes', 60));
|
||||
$stuckProcessingCutoff = now()->subMinutes((int) config('enhance.health.stuck_processing_after_minutes', 30));
|
||||
|
||||
$counts = [
|
||||
'total' => EnhanceJob::query()->count(),
|
||||
'pending' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_PENDING)->count(),
|
||||
'queued' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_QUEUED)->count(),
|
||||
'processing' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_PROCESSING)->count(),
|
||||
'completed' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_COMPLETED)->count(),
|
||||
'failed' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_FAILED)->count(),
|
||||
'cancelled' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_CANCELLED)->count(),
|
||||
'expired' => EnhanceJob::query()->where('status', EnhanceJob::STATUS_EXPIRED)->count(),
|
||||
];
|
||||
|
||||
return [
|
||||
'engine' => (string) config('enhance.default_engine', EnhanceJob::ENGINE_STUB),
|
||||
'queue' => (string) config('enhance.queue', 'default'),
|
||||
'worker_configured' => trim((string) config('enhance.external_worker.url', '')) !== '',
|
||||
'storage_disk' => (string) config('enhance.disk', 'public'),
|
||||
'counts' => $counts,
|
||||
'health' => [
|
||||
'stuck_queued' => EnhanceJob::query()
|
||||
->where('status', EnhanceJob::STATUS_QUEUED)
|
||||
->whereNotNull('queued_at')
|
||||
->where('queued_at', '<=', $stuckQueuedCutoff)
|
||||
->count(),
|
||||
'stuck_processing' => EnhanceJob::query()
|
||||
->where('status', EnhanceJob::STATUS_PROCESSING)
|
||||
->whereNotNull('started_at')
|
||||
->where('started_at', '<=', $stuckProcessingCutoff)
|
||||
->count(),
|
||||
],
|
||||
'today' => [
|
||||
'created' => EnhanceJob::query()->whereBetween('created_at', [$todayStart, $todayEnd])->count(),
|
||||
'completed' => EnhanceJob::query()
|
||||
->where('status', EnhanceJob::STATUS_COMPLETED)
|
||||
->whereBetween('finished_at', [$todayStart, $todayEnd])
|
||||
->count(),
|
||||
'failed' => EnhanceJob::query()
|
||||
->where('status', EnhanceJob::STATUS_FAILED)
|
||||
->whereBetween('finished_at', [$todayStart, $todayEnd])
|
||||
->count(),
|
||||
'average_processing_seconds' => ($average = EnhanceJob::query()
|
||||
->whereNotNull('processing_seconds')
|
||||
->whereBetween('finished_at', [$todayStart, $todayEnd])
|
||||
->avg('processing_seconds')) !== null
|
||||
? round((float) $average, 2)
|
||||
: null,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
290
app/Console/Commands/Enhance/EnhanceRunCommand.php
Normal file
290
app/Console/Commands/Enhance/EnhanceRunCommand.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Enhance;
|
||||
|
||||
use App\Models\EnhanceJob;
|
||||
use App\Services\Enhance\EnhanceProcessorFactory;
|
||||
use App\Services\Enhance\EnhanceStorageService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
final class EnhanceRunCommand extends Command
|
||||
{
|
||||
protected $signature = 'enhance:run
|
||||
{--id= : Process specific job ID(s), comma-separated}
|
||||
{--limit=1 : Max pending/queued jobs to pick up from the queue (0 = all)}
|
||||
{--engine= : Override the processing engine for this run (stub, external_worker)}
|
||||
{--failed : Also include failed jobs when scanning the queue}
|
||||
{--dry-run : Show what would be processed without executing}';
|
||||
|
||||
protected $description = 'Synchronously process pending enhance jobs inline — useful for debugging with -v / -vv / -vvv.';
|
||||
|
||||
private const PROCESSABLE_STATUSES = [
|
||||
EnhanceJob::STATUS_PENDING,
|
||||
EnhanceJob::STATUS_QUEUED,
|
||||
EnhanceJob::STATUS_PROCESSING,
|
||||
EnhanceJob::STATUS_FAILED,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly EnhanceProcessorFactory $processorFactory,
|
||||
private readonly EnhanceStorageService $storage,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$engineOverride = trim((string) $this->option('engine'));
|
||||
$idOption = trim((string) $this->option('id'));
|
||||
$limit = max(0, (int) $this->option('limit'));
|
||||
$includeFailed = (bool) $this->option('failed');
|
||||
|
||||
if ($engineOverride !== '' && ! in_array($engineOverride, [EnhanceJob::ENGINE_STUB, EnhanceJob::ENGINE_EXTERNAL_WORKER], true)) {
|
||||
$this->error("Unknown engine override: {$engineOverride}. Use 'stub' or 'external_worker'.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$jobs = $this->resolveJobs($idOption, $limit, $includeFailed);
|
||||
|
||||
if ($jobs->isEmpty()) {
|
||||
$this->info('No eligible enhance jobs found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf('Found %d job(s) to process.', $jobs->count()));
|
||||
$this->newLine();
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('Dry-run mode — no jobs will be processed.');
|
||||
|
||||
foreach ($jobs as $job) {
|
||||
$engine = $engineOverride !== '' ? "{$engineOverride} (overridden)" : $job->engine;
|
||||
$this->line(sprintf(
|
||||
' [dry-run] Job #%d status=%-12s engine=%-18s scale=%dx mode=%-14s user_id=%d',
|
||||
$job->id,
|
||||
$job->status,
|
||||
$engine,
|
||||
$job->scale,
|
||||
$job->mode,
|
||||
$job->user_id,
|
||||
));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($jobs as $job) {
|
||||
if ($this->processJob($job, $engineOverride)) {
|
||||
$processed++;
|
||||
} else {
|
||||
$failed++;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$this->info(sprintf('Done: %d completed, %d failed.', $processed, $failed));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveJobs(string $idOption, int $limit, bool $includeFailed): Collection
|
||||
{
|
||||
if ($idOption !== '') {
|
||||
$ids = array_filter(array_map('intval', explode(',', $idOption)));
|
||||
|
||||
return EnhanceJob::query()
|
||||
->whereIn('id', $ids)
|
||||
->whereIn('status', self::PROCESSABLE_STATUSES)
|
||||
->get();
|
||||
}
|
||||
|
||||
$statuses = [EnhanceJob::STATUS_PENDING, EnhanceJob::STATUS_QUEUED, EnhanceJob::STATUS_PROCESSING];
|
||||
|
||||
if ($includeFailed) {
|
||||
$statuses[] = EnhanceJob::STATUS_FAILED;
|
||||
}
|
||||
|
||||
$query = EnhanceJob::query()->whereIn('status', $statuses)->oldest();
|
||||
|
||||
if ($limit > 0) {
|
||||
$query->limit($limit);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
private function processJob(EnhanceJob $job, string $engineOverride): bool
|
||||
{
|
||||
$engine = $engineOverride !== '' ? $engineOverride : (string) $job->engine;
|
||||
|
||||
$this->line(sprintf('<comment>--- Job #%d ---</comment>', $job->id));
|
||||
$this->line(sprintf(' Status : %s', $job->status));
|
||||
$this->line(sprintf(' Engine : %s%s', $engine, $engineOverride !== '' ? ' (overridden)' : ''));
|
||||
$this->line(sprintf(' Scale : %dx', $job->scale));
|
||||
$this->line(sprintf(' Mode : %s', $job->mode));
|
||||
$this->line(sprintf(' User : #%d', $job->user_id));
|
||||
|
||||
if ($this->output->isVerbose()) {
|
||||
$this->line(sprintf(
|
||||
' Source : disk=%-10s path=%s',
|
||||
$job->source_disk ?: '(default)',
|
||||
$job->source_path ?: '—',
|
||||
));
|
||||
$this->line(sprintf(
|
||||
' Input : %dx%d size=%s mime=%s',
|
||||
(int) $job->input_width,
|
||||
(int) $job->input_height,
|
||||
$this->formatBytes((int) $job->input_filesize),
|
||||
$job->input_mime ?: '—',
|
||||
));
|
||||
|
||||
if ($job->error_message !== null) {
|
||||
$this->warn(sprintf(' Previous error: %s', $job->error_message));
|
||||
}
|
||||
}
|
||||
|
||||
if (! in_array($job->status, self::PROCESSABLE_STATUSES, true)) {
|
||||
$this->warn(sprintf(' Skipping: status "%s" is not processable.', $job->status));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$job->forceFill([
|
||||
'status' => EnhanceJob::STATUS_PROCESSING,
|
||||
'started_at' => now(),
|
||||
'finished_at' => null,
|
||||
'error_message' => null,
|
||||
])->save();
|
||||
|
||||
$started = microtime(true);
|
||||
$completedExpiryDays = (int) config('enhance.lifecycle.completed_expires_after_days', 30);
|
||||
|
||||
try {
|
||||
$this->line(' Processing...');
|
||||
|
||||
$processor = $this->processorFactory->make($engine);
|
||||
$result = $processor->process($job);
|
||||
|
||||
if ($this->output->isVerbose()) {
|
||||
$this->line(sprintf(
|
||||
' Output : %dx%d size=%s mime=%s',
|
||||
$result->width,
|
||||
$result->height,
|
||||
$this->formatBytes($result->filesize),
|
||||
$result->mime,
|
||||
));
|
||||
$this->line(sprintf(
|
||||
' Stored : disk=%-10s path=%s',
|
||||
$result->disk,
|
||||
$result->path,
|
||||
));
|
||||
}
|
||||
|
||||
$this->line(' Generating preview...');
|
||||
$preview = $this->storage->createPreviewFromStoredOutput($job, $result->disk, $result->path) ?? [];
|
||||
|
||||
$outputHash = null;
|
||||
$outputContents = Storage::disk($result->disk)->get($result->path);
|
||||
|
||||
if (is_string($outputContents) && $outputContents !== '') {
|
||||
$outputHash = hash('sha256', $outputContents);
|
||||
}
|
||||
|
||||
$job->forceFill([
|
||||
'status' => EnhanceJob::STATUS_COMPLETED,
|
||||
'output_disk' => $result->disk,
|
||||
'output_path' => $result->path,
|
||||
'output_hash' => $outputHash,
|
||||
'output_width' => $result->width,
|
||||
'output_height' => $result->height,
|
||||
'output_filesize' => $result->filesize,
|
||||
'output_mime' => $result->mime,
|
||||
'metadata' => array_merge($job->metadata ?? [], $result->metadata ?? []),
|
||||
'processing_seconds' => (int) round(microtime(true) - $started),
|
||||
'finished_at' => now(),
|
||||
'expires_at' => $completedExpiryDays > 0 ? now()->addDays($completedExpiryDays) : null,
|
||||
] + $preview)->save();
|
||||
|
||||
$elapsed = round(microtime(true) - $started, 2);
|
||||
$this->info(sprintf(' Completed in %.2fs', $elapsed));
|
||||
|
||||
if ($this->output->isVerbose() && ! empty($result->metadata)) {
|
||||
$this->line(' Metadata:');
|
||||
|
||||
foreach ($result->metadata as $key => $value) {
|
||||
$display = is_scalar($value) ? (string) $value : json_encode($value);
|
||||
$this->line(sprintf(' %-30s %s', $key . ':', $display));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (Throwable $exception) {
|
||||
$elapsed = round(microtime(true) - $started, 2);
|
||||
|
||||
$job->forceFill([
|
||||
'status' => EnhanceJob::STATUS_FAILED,
|
||||
'error_message' => Str::limit($exception->getMessage(), 1000),
|
||||
'processing_seconds' => (int) round(microtime(true) - $started),
|
||||
'finished_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->error(sprintf(' Failed in %.2fs: %s', $elapsed, $exception->getMessage()));
|
||||
|
||||
if ($this->output->isVerbose()) {
|
||||
$this->line(sprintf(' Exception : %s', get_class($exception)));
|
||||
$this->line(sprintf(' At : %s:%d', $exception->getFile(), $exception->getLine()));
|
||||
|
||||
$previous = $exception->getPrevious();
|
||||
|
||||
if ($previous !== null) {
|
||||
$this->line(sprintf(' Caused by : %s: %s', get_class($previous), $previous->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->output->isVeryVerbose()) {
|
||||
$this->line(' Stack trace:');
|
||||
$frames = array_slice(explode("\n", $exception->getTraceAsString()), 0, 25);
|
||||
|
||||
foreach ($frames as $frame) {
|
||||
$this->line(' ' . $frame);
|
||||
}
|
||||
}
|
||||
|
||||
Log::warning('enhance.run.command.failed', [
|
||||
'enhance_job_id' => $job->id,
|
||||
'engine' => $engine,
|
||||
'message' => $exception->getMessage(),
|
||||
'exception' => get_class($exception),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes < 1024) {
|
||||
return $bytes . 'B';
|
||||
}
|
||||
|
||||
if ($bytes < 1_048_576) {
|
||||
return round($bytes / 1024, 1) . 'KB';
|
||||
}
|
||||
|
||||
return round($bytes / 1_048_576, 1) . 'MB';
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Console\Commands;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use App\Services\News\NewsService;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
|
||||
final class PublishScheduledNewsCommand extends Command
|
||||
@@ -17,6 +18,11 @@ final class PublishScheduledNewsCommand extends Command
|
||||
|
||||
protected $description = 'Publish scheduled News articles whose publish time has passed.';
|
||||
|
||||
public function __construct(private readonly NewsService $news)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
@@ -60,11 +66,7 @@ final class PublishScheduledNewsCommand extends Command
|
||||
return;
|
||||
}
|
||||
|
||||
$article->forceFill([
|
||||
'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED,
|
||||
'status' => 'published',
|
||||
'published_at' => $article->published_at ?? $now,
|
||||
])->save();
|
||||
$this->news->publish($article);
|
||||
|
||||
$published++;
|
||||
$this->line(sprintf('Published News article #%d: "%s"', $article->id, $article->title));
|
||||
|
||||
@@ -85,6 +85,13 @@ final class AcademyCourseController extends Controller
|
||||
'featuredCourses' => $featuredCourses->all(),
|
||||
'filters' => $filters,
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
'lessonsUrl' => route('academy.lessons.index'),
|
||||
'promptLibraryUrl' => route('academy.prompts.index'),
|
||||
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
|
||||
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
|
||||
? route('academy.billing.account')
|
||||
: route('academy.pricing'),
|
||||
]),
|
||||
'analytics' => [
|
||||
'enabled' => true,
|
||||
'contentType' => null,
|
||||
|
||||
@@ -60,10 +60,16 @@ final class AcademyHomeController extends Controller
|
||||
return Inertia::render('Academy/Index', [
|
||||
'seo' => $seo,
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
|
||||
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
|
||||
? route('academy.billing.account')
|
||||
: route('academy.pricing'),
|
||||
]),
|
||||
'links' => [
|
||||
'lessons' => route('academy.lessons.index'),
|
||||
'courses' => route('academy.courses.index'),
|
||||
'prompts' => route('academy.prompts.index'),
|
||||
'promptPopular' => route('academy.prompts.popular'),
|
||||
'packs' => route('academy.packs.index'),
|
||||
'challenges' => route('academy.challenges.index'),
|
||||
],
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Services\Academy\AcademyCacheService;
|
||||
use App\Services\Academy\AcademyInteractionService;
|
||||
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
@@ -27,7 +28,7 @@ final class AcademyLessonController extends Controller
|
||||
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);
|
||||
|
||||
@@ -65,6 +66,10 @@ final class AcademyLessonController extends Controller
|
||||
$this->analytics->trackSearch((string) $filters['q'], (int) $lessons->total(), array_filter($filters), $request);
|
||||
}
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json($lessons);
|
||||
}
|
||||
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionListing(
|
||||
'Academy Lessons — Skinbase',
|
||||
@@ -78,10 +83,21 @@ final class AcademyLessonController extends Controller
|
||||
'title' => 'Academy lessons',
|
||||
'description' => 'Step-by-step tutorials and workflow guides for AI-assisted creative work on Skinbase.',
|
||||
'seo' => $seo,
|
||||
'breadcrumbs' => [
|
||||
['label' => 'Academy', 'href' => route('academy.index')],
|
||||
['label' => 'Lessons', 'href' => route('academy.lessons.index')],
|
||||
],
|
||||
'items' => $lessons,
|
||||
'filters' => $filters,
|
||||
'categories' => $this->cache->categoriesByType('lesson'),
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
'coursesUrl' => route('academy.courses.index'),
|
||||
'promptLibraryUrl' => route('academy.prompts.index'),
|
||||
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
|
||||
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
|
||||
? route('academy.billing.account')
|
||||
: route('academy.pricing'),
|
||||
]),
|
||||
'analytics' => [
|
||||
'enabled' => true,
|
||||
'contentType' => filled($filters['q'] ?? null) ? AcademyAnalyticsContentType::SEARCH : null,
|
||||
|
||||
@@ -10,11 +10,13 @@ use App\Services\Academy\AcademyAccessService;
|
||||
use App\Services\Academy\AcademyAnalyticsService;
|
||||
use App\Services\Academy\AcademyCacheService;
|
||||
use App\Services\Academy\AcademyInteractionService;
|
||||
use App\Services\Academy\AcademyPopularityService;
|
||||
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -25,6 +27,7 @@ final class AcademyPromptController extends Controller
|
||||
private readonly AcademyCacheService $cache,
|
||||
private readonly AcademyAnalyticsService $analytics,
|
||||
private readonly AcademyInteractionService $interactions,
|
||||
private readonly AcademyPopularityService $popularity,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -86,16 +89,32 @@ final class AcademyPromptController extends Controller
|
||||
|
||||
return Inertia::render('Academy/List', [
|
||||
'pageType' => 'prompts',
|
||||
'promptView' => 'library',
|
||||
'title' => 'Prompt library',
|
||||
'description' => 'Reusable prompt templates for wallpapers, worlds, mascots, covers, and digital art workflows.',
|
||||
'seo' => $seo,
|
||||
'breadcrumbs' => [
|
||||
['label' => 'Academy', 'href' => route('academy.index')],
|
||||
['label' => 'Prompt Library', 'href' => route('academy.prompts.index')],
|
||||
],
|
||||
'items' => $prompts,
|
||||
'filters' => $filters,
|
||||
'categories' => $this->cache->categoriesByType('prompt'),
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
'coursesUrl' => route('academy.courses.index'),
|
||||
'packsUrl' => route('academy.packs.index'),
|
||||
'promptPopularUrl' => route('academy.prompts.popular'),
|
||||
'promptLibraryUrl' => route('academy.prompts.index'),
|
||||
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
|
||||
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
|
||||
? route('academy.billing.account')
|
||||
: route('academy.pricing'),
|
||||
]),
|
||||
'featuredPrompts' => $this->featuredPromptPayloads($request->user()),
|
||||
'popularPrompts' => $this->popularPromptPayloads($request->user()),
|
||||
'analytics' => [
|
||||
'enabled' => true,
|
||||
'contentType' => filled($filters['q'] ?? null) ? AcademyAnalyticsContentType::SEARCH : null,
|
||||
'contentType' => AcademyAnalyticsContentType::PROMPT_LIBRARY,
|
||||
'contentId' => null,
|
||||
'eventUrl' => route('academy.analytics.events.store'),
|
||||
'pageName' => 'academy_prompts_index',
|
||||
@@ -110,6 +129,186 @@ final class AcademyPromptController extends Controller
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function popular(Request $request): Response
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'period' => ['nullable', 'string', 'in:7d,30d,90d'],
|
||||
]);
|
||||
|
||||
$selectedPeriod = $this->selectedPopularPromptPeriod($validated['period'] ?? null);
|
||||
$from = now()->subDays($selectedPeriod['days'] - 1)->startOfDay();
|
||||
$to = now()->endOfDay();
|
||||
|
||||
$rows = DB::query()
|
||||
->fromSub(
|
||||
$this->popularity->queryBetween($from, $to)
|
||||
->where('content_type', AcademyAnalyticsContentType::PROMPT)
|
||||
->whereNotNull('content_id')
|
||||
->selectRaw('content_id, sum(views) as views, sum(prompt_copies) as prompt_copies, sum(popularity_score) as popularity_score')
|
||||
->groupBy('content_id'),
|
||||
'prompt_rankings'
|
||||
)
|
||||
->orderByDesc('popularity_score')
|
||||
->orderByDesc('prompt_copies')
|
||||
->orderByDesc('views')
|
||||
->paginate(12)
|
||||
->withQueryString();
|
||||
|
||||
$prompts = AcademyPromptTemplate::query()
|
||||
->with('category')
|
||||
->active()
|
||||
->published()
|
||||
->whereIn('id', $rows->pluck('content_id')->map(static fn ($value): int => (int) $value)->all())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$baseRank = (($rows->currentPage() - 1) * $rows->perPage());
|
||||
|
||||
$rows->setCollection(
|
||||
$rows->getCollection()
|
||||
->values()
|
||||
->map(function (object $row, int $index) use ($prompts, $request, $baseRank, $selectedPeriod): ?array {
|
||||
$prompt = $prompts->get((int) $row->content_id);
|
||||
|
||||
if (! $prompt instanceof AcademyPromptTemplate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = $this->access->promptPayload($prompt, $request->user());
|
||||
$payload['ranking'] = [
|
||||
'rank' => $baseRank + $index + 1,
|
||||
'views' => max(0, (int) ($row->views ?? 0)),
|
||||
'prompt_copies' => max(0, (int) ($row->prompt_copies ?? 0)),
|
||||
'popularity_score' => round((float) ($row->popularity_score ?? 0), 2),
|
||||
];
|
||||
$payload['spotlight'] = [
|
||||
'eyebrow' => max(0, (int) ($row->prompt_copies ?? 0)) > 0
|
||||
? sprintf('%d copies %s', (int) $row->prompt_copies, $selectedPeriod['eyebrow_suffix'])
|
||||
: sprintf('%d views %s', (int) $row->views, $selectedPeriod['eyebrow_suffix']),
|
||||
];
|
||||
|
||||
return $payload;
|
||||
})
|
||||
->filter()
|
||||
->values()
|
||||
);
|
||||
|
||||
$seo = app(SeoFactory::class)
|
||||
->collectionListing(
|
||||
sprintf('%s Prompts — Skinbase Academy', $selectedPeriod['title_prefix']),
|
||||
sprintf('See which Skinbase Academy prompt templates are driving the most views and copies %s.', $selectedPeriod['description_suffix']),
|
||||
route('academy.prompts.popular', $request->query()),
|
||||
)
|
||||
->toArray();
|
||||
|
||||
return Inertia::render('Academy/List', [
|
||||
'pageType' => 'prompts',
|
||||
'promptView' => 'popular',
|
||||
'title' => sprintf('%s prompts', $selectedPeriod['title_prefix']),
|
||||
'description' => sprintf('The prompt templates getting the most momentum from views and copies across the Academy %s.', $selectedPeriod['description_suffix']),
|
||||
'seo' => $seo,
|
||||
'breadcrumbs' => [
|
||||
['label' => 'Academy', 'href' => route('academy.index')],
|
||||
['label' => 'Prompt Library', 'href' => route('academy.prompts.index')],
|
||||
['label' => 'Popular Prompts', 'href' => route('academy.prompts.popular')],
|
||||
],
|
||||
'items' => $rows,
|
||||
'filters' => [],
|
||||
'categories' => [],
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
'coursesUrl' => route('academy.courses.index'),
|
||||
'packsUrl' => route('academy.packs.index'),
|
||||
'promptPopularUrl' => route('academy.prompts.popular'),
|
||||
'promptLibraryUrl' => route('academy.prompts.index'),
|
||||
'academyAccess' => array_merge($this->access->accessSummary($request->user()), [
|
||||
'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false)
|
||||
? route('academy.billing.account')
|
||||
: route('academy.pricing'),
|
||||
]),
|
||||
'popularPeriod' => [
|
||||
'value' => $selectedPeriod['value'],
|
||||
'label' => $selectedPeriod['label'],
|
||||
'description' => $selectedPeriod['description'],
|
||||
],
|
||||
'popularPeriods' => collect($this->popularPromptPeriods())
|
||||
->map(fn (array $period): array => [
|
||||
'value' => $period['value'],
|
||||
'label' => $period['label'],
|
||||
'description' => $period['description'],
|
||||
'href' => route('academy.prompts.popular', ['period' => $period['value']]),
|
||||
'active' => $period['value'] === $selectedPeriod['value'],
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
'featuredPrompts' => $this->featuredPromptPayloads($request->user()),
|
||||
'popularPrompts' => [],
|
||||
'analytics' => [
|
||||
'enabled' => true,
|
||||
'contentType' => AcademyAnalyticsContentType::PROMPT_POPULAR,
|
||||
'contentId' => null,
|
||||
'eventUrl' => route('academy.analytics.events.store'),
|
||||
'pageName' => 'academy_prompts_popular',
|
||||
'trackingKey' => sprintf('period:%s', $selectedPeriod['value']),
|
||||
'metadata' => [
|
||||
'period' => $selectedPeriod['value'],
|
||||
'period_days' => $selectedPeriod['days'],
|
||||
],
|
||||
'search' => null,
|
||||
'isPremium' => false,
|
||||
'isGuest' => $request->user() === null,
|
||||
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function popularPromptPeriods(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'value' => '7d',
|
||||
'days' => 7,
|
||||
'label' => '7 days',
|
||||
'description' => 'Fresh momentum from the last 7 days.',
|
||||
'title_prefix' => 'Top 7-day',
|
||||
'description_suffix' => 'in the last 7 days',
|
||||
'eyebrow_suffix' => 'in the last 7 days',
|
||||
],
|
||||
[
|
||||
'value' => '30d',
|
||||
'days' => 30,
|
||||
'label' => '30 days',
|
||||
'description' => 'The default monthly view of prompt momentum.',
|
||||
'title_prefix' => 'Popular',
|
||||
'description_suffix' => 'this month',
|
||||
'eyebrow_suffix' => 'this month',
|
||||
],
|
||||
[
|
||||
'value' => '90d',
|
||||
'days' => 90,
|
||||
'label' => '90 days',
|
||||
'description' => 'Longer-running prompt momentum across the quarter.',
|
||||
'title_prefix' => 'Top 90-day',
|
||||
'description_suffix' => 'in the last 90 days',
|
||||
'eyebrow_suffix' => 'in the last 90 days',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function selectedPopularPromptPeriod(?string $value): array
|
||||
{
|
||||
return collect($this->popularPromptPeriods())
|
||||
->first(fn (array $period): bool => $period['value'] === $value)
|
||||
?? $this->popularPromptPeriods()[1];
|
||||
}
|
||||
|
||||
public function show(Request $request, string $slug): Response
|
||||
{
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
@@ -201,4 +400,70 @@ final class AcademyPromptController extends Controller
|
||||
],
|
||||
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function featuredPromptPayloads(mixed $viewer, int $limit = 4): array
|
||||
{
|
||||
return collect($this->cache->featuredPrompts())
|
||||
->take($limit)
|
||||
->map(function (AcademyPromptTemplate $prompt) use ($viewer): array {
|
||||
$payload = $this->access->promptPayload($prompt, $viewer);
|
||||
$payload['spotlight'] = [
|
||||
'eyebrow' => $prompt->prompt_of_week ? 'Prompt of the week' : 'Featured pick',
|
||||
];
|
||||
|
||||
return $payload;
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function popularPromptPayloads(mixed $viewer, int $limit = 4): array
|
||||
{
|
||||
$rows = $this->popularity->queryBetween(now()->subDays(29)->startOfDay(), now()->endOfDay())
|
||||
->where('content_type', AcademyAnalyticsContentType::PROMPT)
|
||||
->whereNotNull('content_id')
|
||||
->selectRaw('content_id, sum(views) as views, sum(prompt_copies) as prompt_copies, sum(popularity_score) as popularity_score')
|
||||
->groupBy('content_id')
|
||||
->orderByDesc('popularity_score')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$prompts = AcademyPromptTemplate::query()
|
||||
->with('category')
|
||||
->active()
|
||||
->published()
|
||||
->whereIn('id', $rows->pluck('content_id')->all())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
return $rows->map(function ($row) use ($prompts, $viewer): ?array {
|
||||
$prompt = $prompts->get((int) $row->content_id);
|
||||
|
||||
if (! $prompt instanceof AcademyPromptTemplate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = $this->access->promptPayload($prompt, $viewer);
|
||||
$copies = max(0, (int) ($row->prompt_copies ?? 0));
|
||||
$views = max(0, (int) ($row->views ?? 0));
|
||||
$payload['spotlight'] = [
|
||||
'eyebrow' => $copies > 0 ? sprintf('%d copies this month', $copies) : sprintf('%d views this month', $views),
|
||||
];
|
||||
|
||||
return $payload;
|
||||
})
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ final class AcademyPromptPackController extends Controller
|
||||
abort_unless((bool) config('academy.enabled', true), 404);
|
||||
|
||||
$packs = AcademyPromptPack::query()
|
||||
->with('prompts')
|
||||
->active()
|
||||
->published()
|
||||
->latest('published_at')
|
||||
@@ -57,7 +56,7 @@ final class AcademyPromptPackController extends Controller
|
||||
'pricingUrl' => route('academy.pricing'),
|
||||
'analytics' => [
|
||||
'enabled' => true,
|
||||
'contentType' => null,
|
||||
'contentType' => AcademyAnalyticsContentType::PROMPT_PACK_LIBRARY,
|
||||
'contentId' => null,
|
||||
'eventUrl' => route('academy.analytics.events.store'),
|
||||
'pageName' => 'academy_packs_index',
|
||||
|
||||
@@ -53,7 +53,12 @@ class LinkPreviewController extends Controller
|
||||
return response()->json(['error' => 'Invalid URL.'], 422);
|
||||
}
|
||||
|
||||
// Resolve hostname and block private/loopback IPs (SSRF protection)
|
||||
// Resolve hostname and block private/loopback IPs (SSRF protection).
|
||||
// NOTE: This check is not atomic with Guzzle's own DNS resolution — a
|
||||
// DNS rebinding attack could theoretically pass this check and then
|
||||
// resolve to an internal IP when Guzzle makes the actual request.
|
||||
// Risk is low (requires attacker-controlled DNS with very short TTL),
|
||||
// but this is a known limitation of the current approach.
|
||||
$resolved = gethostbyname($host);
|
||||
if ($this->isBlockedIp($resolved)) {
|
||||
return response()->json(['error' => 'URL not allowed.'], 422);
|
||||
|
||||
@@ -47,7 +47,9 @@ use App\Uploads\Exceptions\DraftQuotaException;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Services\GroupArtworkReviewService;
|
||||
use App\Support\ArtworkDescriptionContentValidator;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class UploadController extends Controller
|
||||
@@ -534,6 +536,8 @@ final class UploadController extends Controller
|
||||
'nsfw' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$this->ensureValidArtworkDescription($validated);
|
||||
|
||||
$updates = [];
|
||||
foreach (['title', 'category_id', 'description', 'tags', 'license', 'nsfw'] as $field) {
|
||||
if (array_key_exists($field, $validated)) {
|
||||
@@ -635,6 +639,8 @@ final class UploadController extends Controller
|
||||
'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'],
|
||||
]);
|
||||
|
||||
$this->ensureValidArtworkDescription($validated);
|
||||
|
||||
$mode = $validated['mode'] ?? 'now';
|
||||
$visibility = $validated['visibility'] ?? 'public';
|
||||
|
||||
@@ -814,6 +820,8 @@ final class UploadController extends Controller
|
||||
'world_submissions.*.source_surface' => ['nullable', 'string', 'max:80'],
|
||||
]);
|
||||
|
||||
$this->ensureValidArtworkDescription($validated);
|
||||
|
||||
if (! ctype_digit($id)) {
|
||||
return response()->json(['message' => 'Artwork review submission requires an artwork draft id.'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
@@ -842,4 +850,13 @@ final class UploadController extends Controller
|
||||
'group_review_status' => (string) $artwork->group_review_status,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
private function ensureValidArtworkDescription(array $validated): void
|
||||
{
|
||||
foreach (ArtworkDescriptionContentValidator::errors($validated['description'] ?? null) as $message) {
|
||||
throw ValidationException::withMessages([
|
||||
'description' => [$message],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
52
app/Http/Controllers/ArtworkEnhanceController.php
Normal file
52
app/Http/Controllers/ArtworkEnhanceController.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Enhance\EnhanceService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use RuntimeException;
|
||||
|
||||
final class ArtworkEnhanceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EnhanceService $enhanceService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function store(Request $request, int $artwork): RedirectResponse
|
||||
{
|
||||
$artwork = Artwork::query()->findOrFail($artwork);
|
||||
|
||||
$actor = $request->user();
|
||||
abort_unless($actor !== null, 403);
|
||||
|
||||
$isOwner = (int) $artwork->user_id === (int) $actor->id;
|
||||
$isStaff = $actor->isAdmin() || $actor->isModerator();
|
||||
|
||||
abort_unless($isOwner || $isStaff, 403);
|
||||
|
||||
$validated = $request->validate([
|
||||
'scale' => ['required', 'integer', Rule::in((array) config('enhance.allowed_scales', [2, 4]))],
|
||||
'mode' => ['required', 'string', Rule::in((array) config('enhance.allowed_modes', ['standard', 'artwork', 'photo', 'illustration']))],
|
||||
]);
|
||||
|
||||
try {
|
||||
$job = $this->enhanceService->createFromArtwork($actor, $artwork, $validated);
|
||||
} catch (RuntimeException $exception) {
|
||||
return redirect()
|
||||
->route('enhance.create', ['artwork' => $artwork->id])
|
||||
->withErrors([
|
||||
'source' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('enhance.show', ['enhanceJob' => $job])
|
||||
->with('success', 'Artwork enhance job created.');
|
||||
}
|
||||
}
|
||||
208
app/Http/Controllers/EnhanceController.php
Normal file
208
app/Http/Controllers/EnhanceController.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\EnhanceJob;
|
||||
use App\Services\Enhance\EnhanceService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class EnhanceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EnhanceService $enhanceService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->authorize('viewAny', EnhanceJob::class);
|
||||
|
||||
$jobs = EnhanceJob::query()
|
||||
->where('user_id', (int) $request->user()->id)
|
||||
->with('artwork:id,title,slug')
|
||||
->latest('id')
|
||||
->paginate(12)
|
||||
->withQueryString()
|
||||
->through(fn (EnhanceJob $job): array => $this->serializeJobListItem($job));
|
||||
|
||||
$latestCompleted = EnhanceJob::query()
|
||||
->where('user_id', (int) $request->user()->id)
|
||||
->where('status', EnhanceJob::STATUS_COMPLETED)
|
||||
->latest('finished_at')
|
||||
->limit(4)
|
||||
->get()
|
||||
->map(fn (EnhanceJob $job): array => $this->serializeJobListItem($job))
|
||||
->all();
|
||||
|
||||
return Inertia::render('Enhance/Index', [
|
||||
'title' => 'Skinbase Enhance',
|
||||
'jobs' => $jobs,
|
||||
'latestCompleted' => $latestCompleted,
|
||||
'createUrl' => route('enhance.create'),
|
||||
'indexUrl' => route('enhance.index'),
|
||||
'dailyLimit' => (int) config('enhance.daily_limit', 10),
|
||||
'enhanceConfig' => $this->enhanceService->frontendConfig(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$this->authorize('create', EnhanceJob::class);
|
||||
|
||||
$selectedArtwork = null;
|
||||
|
||||
if (($artworkId = (int) $request->integer('artwork')) > 0) {
|
||||
$artwork = Artwork::query()
|
||||
->select(['id', 'user_id', 'title', 'slug'])
|
||||
->findOrFail($artworkId);
|
||||
|
||||
$actor = $request->user();
|
||||
abort_unless($actor !== null, 403);
|
||||
|
||||
$isOwner = (int) $artwork->user_id === (int) $actor->id;
|
||||
$isStaff = $actor->isAdmin() || $actor->isModerator();
|
||||
|
||||
abort_unless($isOwner || $isStaff, 403);
|
||||
|
||||
$selectedArtwork = [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'show_url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]),
|
||||
'store_url' => route('artworks.enhance.store', ['artwork' => $artwork->id]),
|
||||
];
|
||||
}
|
||||
|
||||
return Inertia::render('Enhance/Create', [
|
||||
'title' => 'Skinbase Enhance',
|
||||
'options' => $this->optionsPayload(),
|
||||
'storeUrl' => route('enhance.store'),
|
||||
'indexUrl' => route('enhance.index'),
|
||||
'maxUploadMb' => (int) config('enhance.max_upload_mb', 20),
|
||||
'selectedArtwork' => $selectedArtwork,
|
||||
'enhanceConfig' => $this->enhanceService->frontendConfig(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorize('create', EnhanceJob::class);
|
||||
|
||||
$validated = $request->validate([
|
||||
'image' => ['required', 'file', 'mimetypes:image/jpeg,image/png,image/webp', 'max:' . ((int) config('enhance.max_upload_mb', 20) * 1024)],
|
||||
'scale' => ['required', 'integer', Rule::in((array) config('enhance.allowed_scales', [2, 4]))],
|
||||
'mode' => ['required', 'string', Rule::in((array) config('enhance.allowed_modes', ['standard', 'artwork', 'photo', 'illustration']))],
|
||||
]);
|
||||
|
||||
$job = $this->enhanceService->createFromUpload($request->user(), $request->file('image'), $validated);
|
||||
|
||||
return redirect()
|
||||
->route('enhance.show', ['enhanceJob' => $job])
|
||||
->with('success', 'Enhance job created.');
|
||||
}
|
||||
|
||||
public function show(EnhanceJob $enhanceJob): Response
|
||||
{
|
||||
$this->authorize('view', $enhanceJob);
|
||||
$enhanceJob->loadMissing('artwork:id,title,slug');
|
||||
|
||||
return Inertia::render('Enhance/Show', [
|
||||
'title' => 'Enhance Job',
|
||||
'job' => $this->serializeJobDetail($enhanceJob),
|
||||
'indexUrl' => route('enhance.index'),
|
||||
'createUrl' => route('enhance.create'),
|
||||
'enhanceConfig' => $this->enhanceService->frontendConfig(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function retry(EnhanceJob $enhanceJob): RedirectResponse
|
||||
{
|
||||
$this->authorize('retry', $enhanceJob);
|
||||
|
||||
$job = $this->enhanceService->retry($enhanceJob);
|
||||
|
||||
return redirect()
|
||||
->route('enhance.show', ['enhanceJob' => $job])
|
||||
->with('success', 'Enhance job queued again.');
|
||||
}
|
||||
|
||||
public function destroy(EnhanceJob $enhanceJob): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $enhanceJob);
|
||||
|
||||
$this->enhanceService->delete($enhanceJob);
|
||||
|
||||
return redirect()
|
||||
->route('enhance.index')
|
||||
->with('success', 'Enhance job deleted.');
|
||||
}
|
||||
|
||||
private function optionsPayload(): array
|
||||
{
|
||||
return [
|
||||
'modes' => array_map(fn (string $mode): array => [
|
||||
'value' => $mode,
|
||||
'label' => ucfirst($mode),
|
||||
], (array) config('enhance.allowed_modes', [])),
|
||||
'scales' => array_map(fn (int $scale): array => [
|
||||
'value' => $scale,
|
||||
'label' => $scale . 'x',
|
||||
], array_map('intval', (array) config('enhance.allowed_scales', []))),
|
||||
];
|
||||
}
|
||||
|
||||
private function serializeJobListItem(EnhanceJob $job): array
|
||||
{
|
||||
return [
|
||||
'id' => $job->id,
|
||||
'status' => (string) $job->status,
|
||||
'engine' => (string) $job->engine,
|
||||
'mode' => (string) $job->mode,
|
||||
'scale' => (int) $job->scale,
|
||||
'source_url' => $job->sourceUrl(),
|
||||
'output_url' => $job->outputUrl(),
|
||||
'preview_url' => $job->previewUrl(),
|
||||
'input_width' => (int) ($job->input_width ?? 0),
|
||||
'input_height' => (int) ($job->input_height ?? 0),
|
||||
'output_width' => (int) ($job->output_width ?? 0),
|
||||
'output_height' => (int) ($job->output_height ?? 0),
|
||||
'error_message' => $job->error_message,
|
||||
'processing_seconds' => $job->processing_seconds,
|
||||
'created_at' => optional($job->created_at)?->toIso8601String(),
|
||||
'finished_at' => optional($job->finished_at)?->toIso8601String(),
|
||||
'show_url' => route('enhance.show', ['enhanceJob' => $job]),
|
||||
'artwork' => $job->artwork ? [
|
||||
'id' => $job->artwork->id,
|
||||
'title' => $job->artwork->title,
|
||||
'slug' => $job->artwork->slug,
|
||||
'url' => route('art.show', ['id' => $job->artwork->id, 'slug' => $job->artwork->slug]),
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function serializeJobDetail(EnhanceJob $job): array
|
||||
{
|
||||
return $this->serializeJobListItem($job) + [
|
||||
'input_filesize' => (int) ($job->input_filesize ?? 0),
|
||||
'input_mime' => $job->input_mime,
|
||||
'output_filesize' => (int) ($job->output_filesize ?? 0),
|
||||
'output_mime' => $job->output_mime,
|
||||
'metadata' => $job->metadata ?? [],
|
||||
'queued_at' => optional($job->queued_at)?->toIso8601String(),
|
||||
'started_at' => optional($job->started_at)?->toIso8601String(),
|
||||
'deleted_at' => optional($job->deleted_at)?->toIso8601String(),
|
||||
'expires_at' => optional($job->expires_at)?->toIso8601String(),
|
||||
'retry_url' => route('enhance.retry', ['enhanceJob' => $job]),
|
||||
'delete_url' => route('enhance.destroy', ['enhanceJob' => $job]),
|
||||
'download_url' => $job->outputUrl(),
|
||||
'can_retry' => auth()->user()?->can('retry', $job) ?? false,
|
||||
'can_delete' => auth()->user()?->can('delete', $job) ?? false,
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Http/Controllers/Internal/EnhanceSourceController.php
Normal file
39
app/Http/Controllers/Internal/EnhanceSourceController.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Internal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EnhanceJob;
|
||||
use App\Services\Enhance\EnhanceStorageService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Throwable;
|
||||
|
||||
final class EnhanceSourceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EnhanceStorageService $storage,
|
||||
) {
|
||||
}
|
||||
|
||||
public function show(Request $request, EnhanceJob $enhanceJob): Response
|
||||
{
|
||||
abort_unless($request->hasValidSignature(), 403);
|
||||
abort_unless($this->storage->isEnhancePath($enhanceJob->source_path), 404);
|
||||
|
||||
try {
|
||||
$binary = $this->storage->fetchSourceBinary($enhanceJob);
|
||||
} catch (Throwable) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return response($binary, 200, [
|
||||
'Content-Type' => trim((string) ($enhanceJob->input_mime ?: 'application/octet-stream')),
|
||||
'Content-Length' => (string) strlen($binary),
|
||||
'Cache-Control' => 'private, max-age=60',
|
||||
'Content-Disposition' => 'inline; filename="enhance-source-' . $enhanceJob->id . '"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -79,20 +79,28 @@ class UserController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$allowedLegacyMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
|
||||
if ($request->hasFile('personal_picture')) {
|
||||
$f = $request->file('personal_picture');
|
||||
$name = $user->id . '.' . $f->getClientOriginalExtension();
|
||||
if (in_array($f->getMimeType(), $allowedLegacyMimes, true)) {
|
||||
$ext = $f->guessExtension() ?: 'jpg';
|
||||
$name = $user->id . '.' . $ext;
|
||||
$f->move(public_path('user-picture'), $name);
|
||||
$profileUpdates['cover_image'] = $name;
|
||||
$user->picture = $name;
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->hasFile('emotion_icon')) {
|
||||
$f = $request->file('emotion_icon');
|
||||
$name = $user->id . '.' . $f->getClientOriginalExtension();
|
||||
if (in_array($f->getMimeType(), $allowedLegacyMimes, true)) {
|
||||
$ext = $f->guessExtension() ?: 'jpg';
|
||||
$name = $user->id . '.' . $ext;
|
||||
$f->move(public_path('emotion'), $name);
|
||||
$user->eicon = $name;
|
||||
}
|
||||
}
|
||||
|
||||
// Save core user fields
|
||||
$user->save();
|
||||
|
||||
162
app/Http/Controllers/Moderation/ModerationEnhanceController.php
Normal file
162
app/Http/Controllers/Moderation/ModerationEnhanceController.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Moderation;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EnhanceJob;
|
||||
use App\Services\Enhance\EnhanceService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class ModerationEnhanceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EnhanceService $enhanceService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$filters = [
|
||||
'status' => trim((string) $request->query('status', 'all')),
|
||||
'engine' => trim((string) $request->query('engine', 'all')),
|
||||
'mode' => trim((string) $request->query('mode', 'all')),
|
||||
'scale' => trim((string) $request->query('scale', 'all')),
|
||||
'user' => trim((string) $request->query('user', '')),
|
||||
'date_from' => trim((string) $request->query('date_from', '')),
|
||||
'date_to' => trim((string) $request->query('date_to', '')),
|
||||
];
|
||||
|
||||
$jobs = EnhanceJob::query()
|
||||
->with(['user:id,name,username', 'artwork:id,title,slug'])
|
||||
->when($filters['status'] !== '' && $filters['status'] !== 'all', fn ($query) => $query->where('status', $filters['status']))
|
||||
->when($filters['engine'] !== '' && $filters['engine'] !== 'all', fn ($query) => $query->where('engine', $filters['engine']))
|
||||
->when($filters['mode'] !== '' && $filters['mode'] !== 'all', fn ($query) => $query->where('mode', $filters['mode']))
|
||||
->when($filters['scale'] !== '' && $filters['scale'] !== 'all', fn ($query) => $query->where('scale', (int) $filters['scale']))
|
||||
->when($filters['user'] !== '', function ($query) use ($filters): void {
|
||||
$query->whereHas('user', function ($userQuery) use ($filters): void {
|
||||
$userQuery
|
||||
->where('name', 'like', '%' . $filters['user'] . '%')
|
||||
->orWhere('username', 'like', '%' . $filters['user'] . '%');
|
||||
});
|
||||
})
|
||||
->when($filters['date_from'] !== '', fn ($query) => $query->whereDate('created_at', '>=', $filters['date_from']))
|
||||
->when($filters['date_to'] !== '', fn ($query) => $query->whereDate('created_at', '<=', $filters['date_to']))
|
||||
->latest('id')
|
||||
->paginate(20)
|
||||
->withQueryString()
|
||||
->through(fn (EnhanceJob $job): array => $this->serializeJob($job));
|
||||
|
||||
return Inertia::render('Moderation/Enhance/Index', [
|
||||
'title' => 'Enhance Jobs',
|
||||
'jobs' => $jobs,
|
||||
'filters' => $filters,
|
||||
'options' => [
|
||||
'statuses' => ['all', 'pending', 'queued', 'processing', 'completed', 'failed', 'cancelled', 'expired'],
|
||||
'engines' => ['all', 'stub', 'external_worker'],
|
||||
'modes' => array_merge(['all'], (array) config('enhance.allowed_modes', [])),
|
||||
'scales' => array_merge(['all'], array_map('intval', (array) config('enhance.allowed_scales', []))),
|
||||
],
|
||||
'indexUrl' => route('admin.enhance.index'),
|
||||
'enhanceConfig' => $this->enhanceService->frontendConfig(),
|
||||
])->rootView('moderation');
|
||||
}
|
||||
|
||||
public function show(EnhanceJob $enhanceJob): Response
|
||||
{
|
||||
$enhanceJob->loadMissing(['user:id,name,username', 'artwork:id,title,slug']);
|
||||
|
||||
return Inertia::render('Moderation/Enhance/Show', [
|
||||
'title' => 'Enhance Job #' . $enhanceJob->id,
|
||||
'job' => $this->serializeJob($enhanceJob, true),
|
||||
'indexUrl' => route('admin.enhance.index'),
|
||||
'enhanceConfig' => $this->enhanceService->frontendConfig(),
|
||||
])->rootView('moderation');
|
||||
}
|
||||
|
||||
public function retry(EnhanceJob $enhanceJob): RedirectResponse
|
||||
{
|
||||
$this->authorize('retry', $enhanceJob);
|
||||
|
||||
$job = $this->enhanceService->retry($enhanceJob);
|
||||
|
||||
return redirect()
|
||||
->route('admin.enhance.show', ['enhanceJob' => $job])
|
||||
->with('success', 'Enhance job queued again.');
|
||||
}
|
||||
|
||||
public function markFailed(Request $request, EnhanceJob $enhanceJob): RedirectResponse
|
||||
{
|
||||
$this->authorize('markFailed', $enhanceJob);
|
||||
|
||||
$job = $this->enhanceService->markFailedByModerator($enhanceJob, $request->user());
|
||||
|
||||
return redirect()
|
||||
->route('admin.enhance.show', ['enhanceJob' => $job])
|
||||
->with('success', 'Enhance job marked as failed.');
|
||||
}
|
||||
|
||||
public function destroy(EnhanceJob $enhanceJob): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $enhanceJob);
|
||||
|
||||
$this->enhanceService->delete($enhanceJob);
|
||||
|
||||
return redirect()
|
||||
->route('admin.enhance.index')
|
||||
->with('success', 'Enhance job deleted.');
|
||||
}
|
||||
|
||||
private function serializeJob(EnhanceJob $job, bool $detailed = false): array
|
||||
{
|
||||
return [
|
||||
'id' => $job->id,
|
||||
'status' => (string) $job->status,
|
||||
'engine' => (string) $job->engine,
|
||||
'mode' => (string) $job->mode,
|
||||
'scale' => (int) $job->scale,
|
||||
'source_url' => $job->sourceUrl(),
|
||||
'output_url' => $job->outputUrl(),
|
||||
'preview_url' => $job->previewUrl(),
|
||||
'input_width' => (int) ($job->input_width ?? 0),
|
||||
'input_height' => (int) ($job->input_height ?? 0),
|
||||
'input_filesize' => (int) ($job->input_filesize ?? 0),
|
||||
'input_mime' => $job->input_mime,
|
||||
'output_width' => (int) ($job->output_width ?? 0),
|
||||
'output_height' => (int) ($job->output_height ?? 0),
|
||||
'output_filesize' => (int) ($job->output_filesize ?? 0),
|
||||
'output_mime' => $job->output_mime,
|
||||
'processing_seconds' => $job->processing_seconds,
|
||||
'error_message' => $job->error_message,
|
||||
'metadata' => $job->metadata ?? [],
|
||||
'created_at' => optional($job->created_at)?->toIso8601String(),
|
||||
'queued_at' => optional($job->queued_at)?->toIso8601String(),
|
||||
'started_at' => optional($job->started_at)?->toIso8601String(),
|
||||
'finished_at' => optional($job->finished_at)?->toIso8601String(),
|
||||
'expires_at' => optional($job->expires_at)?->toIso8601String(),
|
||||
'user' => $job->user ? [
|
||||
'id' => $job->user->id,
|
||||
'name' => $job->user->name,
|
||||
'username' => $job->user->username,
|
||||
] : null,
|
||||
'artwork' => $job->artwork ? [
|
||||
'id' => $job->artwork->id,
|
||||
'title' => $job->artwork->title,
|
||||
'slug' => $job->artwork->slug,
|
||||
'url' => route('art.show', ['id' => $job->artwork->id, 'slug' => $job->artwork->slug]),
|
||||
] : null,
|
||||
'show_url' => route('admin.enhance.show', ['enhanceJob' => $job]),
|
||||
'download_url' => $job->outputUrl(),
|
||||
'retry_url' => route('admin.enhance.retry', ['enhanceJob' => $job]),
|
||||
'mark_failed_url' => route('admin.enhance.mark-failed', ['enhanceJob' => $job]),
|
||||
'delete_url' => route('admin.enhance.destroy', ['enhanceJob' => $job]),
|
||||
'can_retry' => $job->status === EnhanceJob::STATUS_FAILED,
|
||||
'can_mark_failed' => in_array($job->status, [EnhanceJob::STATUS_PENDING, EnhanceJob::STATUS_QUEUED, EnhanceJob::STATUS_PROCESSING], true),
|
||||
'detailed' => $detailed,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,32 @@ class NewsController extends Controller
|
||||
] + $this->sidebarData());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Type page — /news/type/{type}
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public function type(Request $request, string $type): View
|
||||
{
|
||||
$typeLabels = \cPad\Plugins\News\Models\NewsArticle::TYPE_LABELS;
|
||||
|
||||
abort_unless(array_key_exists($type, $typeLabels), 404);
|
||||
|
||||
$label = $typeLabels[$type];
|
||||
$perPage = config('news.articles_per_page', 12);
|
||||
|
||||
$articles = NewsArticle::with('author', 'category')
|
||||
->published()
|
||||
->where('type', $type)
|
||||
->editorialOrder()
|
||||
->paginate($perPage);
|
||||
|
||||
return view('news.type', [
|
||||
'type' => $type,
|
||||
'typeLabel' => $label,
|
||||
'articles' => $articles,
|
||||
] + $this->sidebarData());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Article page — /news/{slug}
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -173,6 +199,7 @@ class NewsController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
NewsView::create([
|
||||
'article_id' => $article->id,
|
||||
'user_id' => $userId,
|
||||
@@ -181,6 +208,12 @@ class NewsController extends Controller
|
||||
]);
|
||||
|
||||
$article->incrementViews();
|
||||
} catch (\Illuminate\Database\QueryException $e) {
|
||||
// Unique constraint violation — duplicate view, skip silently.
|
||||
if (($e->errorInfo[1] ?? 0) !== 1062) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
if ($canReadSession) {
|
||||
$request->session()->put($session, true);
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\News;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
|
||||
class NewsRssController extends Controller
|
||||
@@ -14,13 +15,17 @@ class NewsRssController extends Controller
|
||||
*/
|
||||
public function feed(): Response
|
||||
{
|
||||
$ttl = max(60, (int) config('news.rss_cache_ttl', 300));
|
||||
|
||||
$xml = Cache::remember('news.rss.feed', $ttl, function (): string {
|
||||
$articles = NewsArticle::with('author', 'category')
|
||||
->published()
|
||||
->orderByDesc('published_at')
|
||||
->limit(config('news.rss_limit', 25))
|
||||
->get();
|
||||
|
||||
$xml = $this->buildRss($articles);
|
||||
return $this->buildRss($articles);
|
||||
});
|
||||
|
||||
return response($xml, 200, [
|
||||
'Content-Type' => 'application/rss+xml; charset=UTF-8',
|
||||
|
||||
@@ -6,11 +6,13 @@ namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AcademyContentMetricDaily;
|
||||
use App\Models\AcademyEvent;
|
||||
use App\Models\AcademySearchLog;
|
||||
use App\Services\Academy\AcademyAnalyticsContentResolver;
|
||||
use App\Services\Academy\AcademyContentIntelligenceService;
|
||||
use App\Services\Academy\AcademyPopularityService;
|
||||
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
|
||||
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -29,6 +31,9 @@ final class AcademyAdminAnalyticsController extends Controller
|
||||
public function overview(Request $request): Response
|
||||
{
|
||||
[$from, $to, $range] = $this->resolveDateRange($request);
|
||||
$promptLibraryCurrent = $this->contentSummary(AcademyAnalyticsContentType::PROMPT_LIBRARY, $from, $to);
|
||||
[$previousFrom, $previousTo] = $this->previousRange($from, $to);
|
||||
$promptLibraryPrevious = $this->contentSummary(AcademyAnalyticsContentType::PROMPT_LIBRARY, $previousFrom, $previousTo);
|
||||
|
||||
$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')
|
||||
@@ -50,6 +55,21 @@ final class AcademyAdminAnalyticsController extends Controller
|
||||
'courseStarts' => (int) ($summary?->starts ?? 0),
|
||||
'upgradeClicks' => (int) ($summary?->upgrade_clicks ?? 0),
|
||||
],
|
||||
'promptLibraryTrend' => [
|
||||
'current' => $promptLibraryCurrent,
|
||||
'previous' => $promptLibraryPrevious,
|
||||
'deltas' => [
|
||||
'views' => $this->percentDelta((int) $promptLibraryCurrent['views'], (int) $promptLibraryPrevious['views']),
|
||||
'uniqueVisitors' => $this->percentDelta((int) $promptLibraryCurrent['uniqueVisitors'], (int) $promptLibraryPrevious['uniqueVisitors']),
|
||||
'engagedViews' => $this->percentDelta((int) $promptLibraryCurrent['engagedViews'], (int) $promptLibraryPrevious['engagedViews']),
|
||||
'engagementRate' => $this->percentDelta((float) $promptLibraryCurrent['engagementRate'], (float) $promptLibraryPrevious['engagementRate']),
|
||||
],
|
||||
'range' => [
|
||||
'current' => ['from' => $from->toDateString(), 'to' => $to->toDateString()],
|
||||
'previous' => ['from' => $previousFrom->toDateString(), 'to' => $previousTo->toDateString()],
|
||||
],
|
||||
],
|
||||
'popularPromptPeriodUsage' => $this->popularPromptPeriodUsage($from, $to),
|
||||
'topContent' => $this->serializeContentRows($this->popularity->topContent($from, $to, 8)),
|
||||
'topWeek' => $this->serializeContentRows($this->popularity->topContent(now()->subDays(6)->startOfDay(), now()->endOfDay(), 8)),
|
||||
]);
|
||||
@@ -65,6 +85,11 @@ final class AcademyAdminAnalyticsController extends Controller
|
||||
return $this->renderContentPage($request, AcademyAnalyticsContentType::PROMPT, 'Prompt analytics', 'Copy-heavy prompt performance, save rates, and upgrade interest.');
|
||||
}
|
||||
|
||||
public function promptLibrary(Request $request): Response
|
||||
{
|
||||
return $this->renderContentPage($request, AcademyAnalyticsContentType::PROMPT_LIBRARY, 'Prompt library analytics', 'Discovery and engagement on the public /academy/prompts library page.');
|
||||
}
|
||||
|
||||
public function lessons(Request $request): Response
|
||||
{
|
||||
return $this->renderContentPage($request, AcademyAnalyticsContentType::LESSON, 'Lesson analytics', 'Lesson engagement, starts, completions, and drop-off signals.');
|
||||
@@ -333,9 +358,14 @@ final class AcademyAdminAnalyticsController extends Controller
|
||||
'access' => $access,
|
||||
'content_type' => $contentType,
|
||||
],
|
||||
'summary' => $contentType === AcademyAnalyticsContentType::PROMPT_LIBRARY
|
||||
? $this->contentSummary(AcademyAnalyticsContentType::PROMPT_LIBRARY, $from, $to)
|
||||
: null,
|
||||
'rows' => $serializedRows,
|
||||
'contentTypeOptions' => [
|
||||
['value' => '', 'label' => 'All content'],
|
||||
['value' => AcademyAnalyticsContentType::PROMPT_LIBRARY, 'label' => 'Prompt library'],
|
||||
['value' => AcademyAnalyticsContentType::PROMPT_PACK_LIBRARY, 'label' => 'Prompt pack library'],
|
||||
['value' => AcademyAnalyticsContentType::PROMPT, 'label' => 'Prompts'],
|
||||
['value' => AcademyAnalyticsContentType::LESSON, 'label' => 'Lessons'],
|
||||
['value' => AcademyAnalyticsContentType::COURSE, 'label' => 'Courses'],
|
||||
@@ -359,7 +389,134 @@ final class AcademyAdminAnalyticsController extends Controller
|
||||
private function metricsQuery(Carbon $from, Carbon $to)
|
||||
{
|
||||
return AcademyContentMetricDaily::query()
|
||||
->whereBetween('date', [$from->toDateString(), $to->toDateString()]);
|
||||
->whereBetween('date', [$from->copy()->startOfDay(), $to->copy()->endOfDay()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int|float>
|
||||
*/
|
||||
private function contentSummary(string $contentType, Carbon $from, Carbon $to): array
|
||||
{
|
||||
$query = $this->metricsQuery($from, $to)
|
||||
->where('content_type', $contentType);
|
||||
|
||||
if (! AcademyAnalyticsContentType::requiresContentId($contentType)) {
|
||||
$query->whereNull('content_id');
|
||||
}
|
||||
|
||||
$summary = $query
|
||||
->selectRaw('sum(views) as views, sum(unique_visitors) as unique_visitors, sum(engaged_views) as engaged_views, sum(scroll_50) as scroll_50, sum(scroll_75) as scroll_75, sum(scroll_100) as scroll_100, avg(avg_engaged_seconds) as avg_engaged_seconds, sum(popularity_score) as popularity_score')
|
||||
->first();
|
||||
|
||||
$uniqueVisitors = max(0, (int) ($summary?->unique_visitors ?? 0));
|
||||
$engagedViews = max(0, (int) ($summary?->engaged_views ?? 0));
|
||||
$scroll100 = max(0, (int) ($summary?->scroll_100 ?? 0));
|
||||
|
||||
return [
|
||||
'views' => max(0, (int) ($summary?->views ?? 0)),
|
||||
'uniqueVisitors' => $uniqueVisitors,
|
||||
'engagedViews' => $engagedViews,
|
||||
'scroll50' => max(0, (int) ($summary?->scroll_50 ?? 0)),
|
||||
'scroll75' => max(0, (int) ($summary?->scroll_75 ?? 0)),
|
||||
'scroll100' => $scroll100,
|
||||
'avgEngagedSeconds' => round((float) ($summary?->avg_engaged_seconds ?? 0), 1),
|
||||
'popularityScore' => round((float) ($summary?->popularity_score ?? 0), 2),
|
||||
'engagementRate' => $uniqueVisitors > 0 ? round(($engagedViews / $uniqueVisitors) * 100, 1) : 0.0,
|
||||
'deepScrollRate' => $uniqueVisitors > 0 ? round(($scroll100 / $uniqueVisitors) * 100, 1) : 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Carbon, 1: Carbon}
|
||||
*/
|
||||
private function previousRange(Carbon $from, Carbon $to): array
|
||||
{
|
||||
$days = $from->copy()->startOfDay()->diffInDays($to->copy()->startOfDay()) + 1;
|
||||
|
||||
return [
|
||||
$from->copy()->subDays($days)->startOfDay(),
|
||||
$from->copy()->subDay()->endOfDay(),
|
||||
];
|
||||
}
|
||||
|
||||
private function percentDelta(int|float $current, int|float $previous): ?float
|
||||
{
|
||||
if ((float) $previous === 0.0) {
|
||||
return (float) $current === 0.0 ? 0.0 : null;
|
||||
}
|
||||
|
||||
return round((((float) $current - (float) $previous) / (float) $previous) * 100, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{totalViews:int,totalVisitors:int,periods:list<array<string,int|float|string>>}
|
||||
*/
|
||||
private function popularPromptPeriodUsage(Carbon $from, Carbon $to): array
|
||||
{
|
||||
$events = AcademyEvent::query()
|
||||
->whereBetween('occurred_at', [$from, $to])
|
||||
->where('event_type', AcademyAnalyticsEventType::PAGE_VIEW)
|
||||
->where('content_type', AcademyAnalyticsContentType::PROMPT_POPULAR)
|
||||
->get(['visitor_id', 'metadata']);
|
||||
|
||||
$summary = [];
|
||||
$totalViews = 0;
|
||||
$visitorBuckets = [];
|
||||
|
||||
foreach ($events as $event) {
|
||||
$metadata = is_array($event->metadata) ? $event->metadata : [];
|
||||
$period = trim((string) ($metadata['period'] ?? ''));
|
||||
|
||||
if ($period === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$days = max(0, (int) ($metadata['period_days'] ?? 0));
|
||||
|
||||
if (! isset($summary[$period])) {
|
||||
$summary[$period] = [
|
||||
'period' => $period,
|
||||
'label' => sprintf('%s days', $days > 0 ? $days : (int) preg_replace('/\D+/', '', $period)),
|
||||
'views' => 0,
|
||||
'uniqueVisitors' => 0,
|
||||
'share' => 0.0,
|
||||
'days' => $days,
|
||||
];
|
||||
$visitorBuckets[$period] = [];
|
||||
}
|
||||
|
||||
$summary[$period]['views']++;
|
||||
$totalViews++;
|
||||
|
||||
$visitorId = trim((string) ($event->visitor_id ?? ''));
|
||||
if ($visitorId !== '') {
|
||||
$visitorBuckets[$period][$visitorId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$totalVisitors = 0;
|
||||
|
||||
foreach ($summary as $period => &$row) {
|
||||
$uniqueVisitors = count($visitorBuckets[$period] ?? []);
|
||||
$row['uniqueVisitors'] = $uniqueVisitors;
|
||||
$row['share'] = $totalViews > 0 ? round((((int) $row['views']) / $totalViews) * 100, 1) : 0.0;
|
||||
$totalVisitors += $uniqueVisitors;
|
||||
}
|
||||
unset($row);
|
||||
|
||||
usort($summary, static function (array $left, array $right): int {
|
||||
if ((int) $right['views'] === (int) $left['views']) {
|
||||
return ((int) $left['days']) <=> ((int) $right['days']);
|
||||
}
|
||||
|
||||
return ((int) $right['views']) <=> ((int) $left['views']);
|
||||
});
|
||||
|
||||
return [
|
||||
'totalViews' => $totalViews,
|
||||
'totalVisitors' => $totalVisitors,
|
||||
'periods' => array_values($summary),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -440,6 +597,7 @@ final class AcademyAdminAnalyticsController extends Controller
|
||||
['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' => 'Prompt Library', 'href' => route('admin.academy.analytics.prompt-library')],
|
||||
['label' => 'Prompts', 'href' => route('admin.academy.analytics.prompts')],
|
||||
['label' => 'Lessons', 'href' => route('admin.academy.analytics.lessons')],
|
||||
['label' => 'Courses', 'href' => route('admin.academy.analytics.courses')],
|
||||
|
||||
@@ -18,6 +18,7 @@ use App\Services\TagService;
|
||||
use App\Services\ArtworkVersioningService;
|
||||
use App\Services\Studio\StudioArtworkQueryService;
|
||||
use App\Services\Studio\StudioBulkActionService;
|
||||
use App\Support\ArtworkDescriptionContentValidator;
|
||||
use App\Services\Tags\TagDiscoveryService;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use Carbon\Carbon;
|
||||
@@ -164,6 +165,8 @@ final class StudioArtworksApiController extends Controller
|
||||
'evolution_note' => 'sometimes|nullable|string|max:1200',
|
||||
]);
|
||||
|
||||
$this->ensureValidArtworkDescription($validated);
|
||||
|
||||
$hasAttributionUpdates = array_key_exists('group', $validated)
|
||||
|| array_key_exists('primary_author_user_id', $validated)
|
||||
|| array_key_exists('contributor_user_ids', $validated)
|
||||
@@ -326,6 +329,15 @@ final class StudioArtworksApiController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureValidArtworkDescription(array $validated): void
|
||||
{
|
||||
foreach (ArtworkDescriptionContentValidator::errors($validated['description'] ?? null) as $message) {
|
||||
throw ValidationException::withMessages([
|
||||
'description' => [$message],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function evolutionOptions(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
|
||||
@@ -95,7 +95,13 @@ final class StudioController extends Controller
|
||||
{
|
||||
$provider = $this->content->provider('artworks');
|
||||
$prefs = $this->preferences->forUser($request->user());
|
||||
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'bucket', 'page', 'per_page', 'content_type', 'category', 'tag']), null, 'artworks');
|
||||
$filters = $request->only(['q', 'sort', 'bucket', 'page', 'per_page', 'content_type', 'category', 'tag']);
|
||||
|
||||
if (! $request->filled('sort')) {
|
||||
$filters['sort'] = 'published_desc';
|
||||
}
|
||||
|
||||
$listing = $this->content->list($request->user(), $filters, null, 'artworks');
|
||||
$listing['default_view'] = $prefs['default_content_view'];
|
||||
|
||||
return Inertia::render('Studio/StudioArtworks', [
|
||||
|
||||
@@ -377,10 +377,41 @@ final class StudioNewsController extends Controller
|
||||
'og_image' => ['nullable', 'string', 'max:2048'],
|
||||
'relations' => ['nullable', 'array', 'max:12'],
|
||||
'relations.*.entity_type' => ['required_with:relations', Rule::in(array_column($this->news->relationTypeOptions(), 'value'))],
|
||||
'relations.*.entity_id' => ['required_with:relations', 'integer', 'min:1'],
|
||||
'relations.*.entity_id' => ['nullable', 'integer', 'min:1'],
|
||||
'relations.*.external_url' => ['nullable', 'string', 'max:2048'],
|
||||
'relations.*.context_label' => ['nullable', 'string', 'max:120'],
|
||||
]);
|
||||
|
||||
$relationErrors = [];
|
||||
|
||||
foreach ((array) ($validated['relations'] ?? []) as $index => $relation) {
|
||||
$entityType = trim(Str::lower((string) ($relation['entity_type'] ?? '')));
|
||||
|
||||
if ($entityType === NewsService::RELATION_SOURCE) {
|
||||
$externalUrl = $this->normalizeExternalRelationUrl($relation['external_url'] ?? null);
|
||||
|
||||
if ($externalUrl === null) {
|
||||
$relationErrors["relations.{$index}.external_url"] = 'Source relations need a valid URL.';
|
||||
continue;
|
||||
}
|
||||
|
||||
$validated['relations'][$index]['entity_id'] = null;
|
||||
$validated['relations'][$index]['external_url'] = $externalUrl;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int) ($relation['entity_id'] ?? 0) < 1) {
|
||||
$relationErrors["relations.{$index}.entity_id"] = 'Select a related entity.';
|
||||
}
|
||||
|
||||
$validated['relations'][$index]['external_url'] = null;
|
||||
}
|
||||
|
||||
if ($relationErrors !== []) {
|
||||
throw ValidationException::withMessages($relationErrors);
|
||||
}
|
||||
|
||||
if (($validated['editorial_status'] ?? null) === NewsArticle::EDITORIAL_STATUS_SCHEDULED && empty($validated['published_at'])) {
|
||||
throw ValidationException::withMessages([
|
||||
'published_at' => 'Scheduled articles need a publish date and time.',
|
||||
@@ -390,6 +421,25 @@ final class StudioNewsController extends Controller
|
||||
return $validated;
|
||||
}
|
||||
|
||||
private function normalizeExternalRelationUrl(mixed $value): ?string
|
||||
{
|
||||
$url = trim((string) ($value ?? ''));
|
||||
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^\[[^\]]+\]\((https?:\/\/[^)]+)\)$/i', $url, $matches) === 1) {
|
||||
$url = trim((string) ($matches[1] ?? ''));
|
||||
}
|
||||
|
||||
if ($url === '' || filter_var($url, FILTER_VALIDATE_URL) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Str::limit($url, 2048, '');
|
||||
}
|
||||
|
||||
private function tagPayload(): array
|
||||
{
|
||||
return NewsTag::query()
|
||||
|
||||
@@ -46,6 +46,7 @@ final class StudioNewsMediaApiController extends Controller
|
||||
'size_bytes' => $stored['size_bytes'],
|
||||
'mobile_url' => $stored['mobile_url'],
|
||||
'desktop_url' => $stored['desktop_url'],
|
||||
'large_url' => $stored['large_url'],
|
||||
'srcset' => $stored['srcset'],
|
||||
]);
|
||||
} catch (RuntimeException $e) {
|
||||
|
||||
@@ -855,19 +855,26 @@ class ProfileController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$allowedImageMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
|
||||
if ($request->hasFile('emoticon')) {
|
||||
$file = $request->file('emoticon');
|
||||
$fname = $file->getClientOriginalName();
|
||||
$path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-emoticons/'.$user->id, $file, $fname);
|
||||
if (in_array($file->getMimeType(), $allowedImageMimes, true)) {
|
||||
$ext = $file->guessExtension() ?: 'jpg';
|
||||
$fname = $user->id . '_emoticon_' . time() . '.' . $ext;
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-emoticons/'.$user->id, $file, $fname);
|
||||
try {
|
||||
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['eicon' => $fname]);
|
||||
} catch (\Exception $e) {}
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->hasFile('photo')) {
|
||||
$file = $request->file('photo');
|
||||
$fname = $file->getClientOriginalName();
|
||||
$path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname);
|
||||
if (in_array($file->getMimeType(), $allowedImageMimes, true)) {
|
||||
$ext = $file->guessExtension() ?: 'jpg';
|
||||
$fname = $user->id . '_photo_' . time() . '.' . $ext;
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname);
|
||||
if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) {
|
||||
$profileUpdates['cover_image'] = $fname;
|
||||
} else {
|
||||
@@ -876,6 +883,7 @@ class ProfileController extends Controller
|
||||
} catch (\Exception $e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) {
|
||||
|
||||
22
app/Http/Middleware/SecurityHeaders.php
Normal file
22
app/Http/Middleware/SecurityHeaders.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SecurityHeaders
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -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:100'],
|
||||
'tags.*' => ['string', 'max:200'],
|
||||
'video_url' => ['nullable', 'string', 'max:2048'],
|
||||
'reading_minutes' => ['required', 'integer', 'min:1', 'max:999'],
|
||||
'featured' => ['required', 'boolean'],
|
||||
|
||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Artworks;
|
||||
|
||||
use App\Support\ArtworkDescriptionContentValidator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final class ArtworkCreateRequest extends FormRequest
|
||||
@@ -32,6 +34,15 @@ final class ArtworkCreateRequest extends FormRequest
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
foreach (ArtworkDescriptionContentValidator::errors($this->input('description')) as $message) {
|
||||
$validator->errors()->add('description', $message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function denyAsNotFound(): void
|
||||
{
|
||||
throw new NotFoundHttpException();
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Http\Requests\Dashboard;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Support\ArtworkDescriptionContentValidator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class UpdateArtworkRequest extends FormRequest
|
||||
@@ -45,6 +47,15 @@ class UpdateArtworkRequest extends FormRequest
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
foreach (ArtworkDescriptionContentValidator::errors($this->input('description')) as $message) {
|
||||
$validator->errors()->add('description', $message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function artwork(): Artwork
|
||||
{
|
||||
if (! $this->artwork) {
|
||||
|
||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Manage;
|
||||
|
||||
use App\Support\ArtworkDescriptionContentValidator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Validator;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final class ManageArtworkUpdateRequest extends FormRequest
|
||||
@@ -48,6 +50,15 @@ final class ManageArtworkUpdateRequest extends FormRequest
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
foreach (ArtworkDescriptionContentValidator::errors($this->input('description')) as $message) {
|
||||
$validator->errors()->add('description', $message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function artwork(): object
|
||||
{
|
||||
if (! $this->artwork) {
|
||||
|
||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Studio;
|
||||
|
||||
use App\Support\ArtworkDescriptionContentValidator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
final class ApplyArtworkAiAssistRequest extends FormRequest
|
||||
{
|
||||
@@ -31,4 +33,13 @@ final class ApplyArtworkAiAssistRequest extends FormRequest
|
||||
'similar_actions.*.state' => ['required_with:similar_actions', Rule::in(['ignored', 'reviewed'])],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
foreach (ArtworkDescriptionContentValidator::errors($this->input('description')) as $message) {
|
||||
$validator->errors()->add('description', $message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
119
app/Jobs/Enhance/ProcessEnhanceJob.php
Normal file
119
app/Jobs/Enhance/ProcessEnhanceJob.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Enhance;
|
||||
|
||||
use App\Models\EnhanceJob;
|
||||
use App\Services\Enhance\EnhanceProcessorFactory;
|
||||
use App\Services\Enhance\EnhanceStorageService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
final class ProcessEnhanceJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 2;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
public function __construct(
|
||||
private readonly int $enhanceJobId,
|
||||
) {
|
||||
$queue = (string) config('enhance.queue', 'default');
|
||||
|
||||
if ($queue !== '') {
|
||||
$this->onQueue($queue);
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(EnhanceProcessorFactory $factory, EnhanceStorageService $storage): void
|
||||
{
|
||||
$enhanceJob = EnhanceJob::query()->find($this->enhanceJobId);
|
||||
|
||||
if (! $enhanceJob instanceof EnhanceJob) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! in_array($enhanceJob->status, [EnhanceJob::STATUS_PENDING, EnhanceJob::STATUS_QUEUED, EnhanceJob::STATUS_PROCESSING, EnhanceJob::STATUS_FAILED], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$enhanceJob->forceFill([
|
||||
'status' => EnhanceJob::STATUS_PROCESSING,
|
||||
'started_at' => now(),
|
||||
'finished_at' => null,
|
||||
'error_message' => null,
|
||||
])->save();
|
||||
|
||||
Log::info('enhance.job.processing', [
|
||||
'enhance_job_id' => $enhanceJob->id,
|
||||
'user_id' => $enhanceJob->user_id,
|
||||
'engine' => $enhanceJob->engine,
|
||||
]);
|
||||
|
||||
$started = microtime(true);
|
||||
$completedExpiryDays = (int) config('enhance.lifecycle.completed_expires_after_days', 30);
|
||||
|
||||
try {
|
||||
$processor = $factory->make((string) $enhanceJob->engine);
|
||||
$result = $processor->process($enhanceJob);
|
||||
$preview = $storage->createPreviewFromStoredOutput($enhanceJob, $result->disk, $result->path) ?? [];
|
||||
$outputHash = null;
|
||||
$outputContents = Storage::disk($result->disk)->get($result->path);
|
||||
|
||||
if (is_string($outputContents) && $outputContents !== '') {
|
||||
$outputHash = hash('sha256', $outputContents);
|
||||
}
|
||||
|
||||
$enhanceJob->forceFill([
|
||||
'status' => EnhanceJob::STATUS_COMPLETED,
|
||||
'output_disk' => $result->disk,
|
||||
'output_path' => $result->path,
|
||||
'output_hash' => $outputHash,
|
||||
'output_width' => $result->width,
|
||||
'output_height' => $result->height,
|
||||
'output_filesize' => $result->filesize,
|
||||
'output_mime' => $result->mime,
|
||||
'metadata' => array_merge($enhanceJob->metadata ?? [], $result->metadata ?? []),
|
||||
'processing_seconds' => (int) round(microtime(true) - $started),
|
||||
'finished_at' => now(),
|
||||
'expires_at' => $completedExpiryDays > 0 ? now()->addDays($completedExpiryDays) : null,
|
||||
] + $preview)->save();
|
||||
|
||||
Log::info('enhance.job.completed', [
|
||||
'enhance_job_id' => $enhanceJob->id,
|
||||
'user_id' => $enhanceJob->user_id,
|
||||
'processing_seconds' => $enhanceJob->processing_seconds,
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
$enhanceJob->forceFill([
|
||||
'status' => EnhanceJob::STATUS_FAILED,
|
||||
'error_message' => Str::limit($exception->getMessage(), 1000),
|
||||
'processing_seconds' => (int) round(microtime(true) - $started),
|
||||
'finished_at' => now(),
|
||||
])->save();
|
||||
|
||||
Log::warning('enhance.job.failed', [
|
||||
'enhance_job_id' => $enhanceJob->id,
|
||||
'user_id' => $enhanceJob->user_id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
157
app/Models/EnhanceJob.php
Normal file
157
app/Models/EnhanceJob.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
final class EnhanceJob extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_QUEUED = 'queued';
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
|
||||
public const ENGINE_STUB = 'stub';
|
||||
public const ENGINE_EXTERNAL_WORKER = 'external_worker';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'artwork_id',
|
||||
'status',
|
||||
'engine',
|
||||
'mode',
|
||||
'scale',
|
||||
'source_disk',
|
||||
'source_path',
|
||||
'source_hash',
|
||||
'input_width',
|
||||
'input_height',
|
||||
'input_filesize',
|
||||
'input_mime',
|
||||
'output_disk',
|
||||
'output_path',
|
||||
'output_hash',
|
||||
'output_width',
|
||||
'output_height',
|
||||
'output_filesize',
|
||||
'output_mime',
|
||||
'preview_disk',
|
||||
'preview_path',
|
||||
'processing_seconds',
|
||||
'error_message',
|
||||
'metadata',
|
||||
'queued_at',
|
||||
'started_at',
|
||||
'finished_at',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'queued_at' => 'datetime',
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function isQueued(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_QUEUED;
|
||||
}
|
||||
|
||||
public function isProcessing(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PROCESSING;
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_COMPLETED;
|
||||
}
|
||||
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_FAILED;
|
||||
}
|
||||
|
||||
public function canBeDeletedBy(User $user): bool
|
||||
{
|
||||
if ($user->isAdmin() || $user->isModerator()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (int) $this->user_id === (int) $user->id
|
||||
&& in_array($this->status, [self::STATUS_PENDING, self::STATUS_FAILED, self::STATUS_COMPLETED, self::STATUS_CANCELLED, self::STATUS_EXPIRED], true);
|
||||
}
|
||||
|
||||
public function sourceUrl(): ?string
|
||||
{
|
||||
return $this->resolveDiskUrl($this->source_disk, $this->source_path);
|
||||
}
|
||||
|
||||
public function outputUrl(): ?string
|
||||
{
|
||||
return $this->resolveDiskUrl($this->output_disk, $this->output_path);
|
||||
}
|
||||
|
||||
public function previewUrl(): ?string
|
||||
{
|
||||
return $this->resolveDiskUrl($this->preview_disk, $this->preview_path);
|
||||
}
|
||||
|
||||
private function resolveDiskUrl(?string $disk, ?string $path): ?string
|
||||
{
|
||||
$trimmedPath = ltrim(trim((string) $path), '/');
|
||||
|
||||
if ($trimmedPath === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$configuredDisk = trim((string) config('enhance.disk', 'public'));
|
||||
$targetDisk = trim((string) $disk) ?: $configuredDisk ?: 'public';
|
||||
|
||||
// For non-local disks (e.g. S3-backed), construct the CDN URL directly.
|
||||
// For local disks ('public', 'local') fall through to Storage::disk()->url()
|
||||
// so that the correct APP_URL-based path is returned in non-CDN environments.
|
||||
$base = rtrim((string) config('cdn.files_url', ''), '/');
|
||||
if ($base !== '' && $targetDisk === $configuredDisk && ! in_array($targetDisk, ['public', 'local'], true)) {
|
||||
return $base . '/' . $trimmedPath;
|
||||
}
|
||||
|
||||
$url = Storage::disk($targetDisk)->url($trimmedPath);
|
||||
|
||||
if (str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return url($url);
|
||||
}
|
||||
}
|
||||
55
app/Policies/EnhanceJobPolicy.php
Normal file
55
app/Policies/EnhanceJobPolicy.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\EnhanceJob;
|
||||
use App\Models\User;
|
||||
|
||||
final class EnhanceJobPolicy
|
||||
{
|
||||
public function before(?User $user, string $ability): ?bool
|
||||
{
|
||||
if ($user && ($user->isAdmin() || $user->isModerator())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function viewAny(?User $user): bool
|
||||
{
|
||||
return $user !== null;
|
||||
}
|
||||
|
||||
public function view(User $user, EnhanceJob $enhanceJob): bool
|
||||
{
|
||||
return (int) $enhanceJob->user_id === (int) $user->id;
|
||||
}
|
||||
|
||||
public function create(?User $user): bool
|
||||
{
|
||||
if ($user === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! method_exists($user, 'hasVerifiedEmail') || $user->hasVerifiedEmail();
|
||||
}
|
||||
|
||||
public function delete(User $user, EnhanceJob $enhanceJob): bool
|
||||
{
|
||||
return $enhanceJob->canBeDeletedBy($user);
|
||||
}
|
||||
|
||||
public function retry(User $user, EnhanceJob $enhanceJob): bool
|
||||
{
|
||||
return (int) $enhanceJob->user_id === (int) $user->id
|
||||
&& $enhanceJob->isFailed();
|
||||
}
|
||||
|
||||
public function markFailed(User $user, EnhanceJob $enhanceJob): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use App\Models\AcademyChallengeSubmission;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyPromptPack;
|
||||
use App\Models\AcademyPromptTemplate;
|
||||
use App\Models\EnhanceJob;
|
||||
use App\Models\Collection;
|
||||
use App\Models\Group;
|
||||
use App\Models\NovaCard;
|
||||
@@ -25,6 +26,7 @@ use App\Policies\AcademyChallengeSubmissionPolicy;
|
||||
use App\Policies\AcademyLessonPolicy;
|
||||
use App\Policies\AcademyPromptPackPolicy;
|
||||
use App\Policies\AcademyPromptTemplatePolicy;
|
||||
use App\Policies\EnhanceJobPolicy;
|
||||
use App\Policies\CollectionPolicy;
|
||||
use App\Policies\GroupPolicy;
|
||||
use App\Policies\NovaCardPolicy;
|
||||
@@ -43,6 +45,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
AcademyLesson::class => AcademyLessonPolicy::class,
|
||||
AcademyPromptPack::class => AcademyPromptPackPolicy::class,
|
||||
AcademyPromptTemplate::class => AcademyPromptTemplatePolicy::class,
|
||||
EnhanceJob::class => EnhanceJobPolicy::class,
|
||||
Collection::class => CollectionPolicy::class,
|
||||
Group::class => GroupPolicy::class,
|
||||
NovaCard::class => NovaCardPolicy::class,
|
||||
|
||||
@@ -92,6 +92,121 @@ final class AcademyAccessService
|
||||
return $this->activeAcademySubscription($user) instanceof Subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function accessSummary(?User $user): array
|
||||
{
|
||||
if (! $user instanceof User) {
|
||||
return [
|
||||
'signedIn' => false,
|
||||
'tier' => 'free',
|
||||
'tierLabel' => 'Guest',
|
||||
'hasPaidAccess' => false,
|
||||
'status' => 'guest',
|
||||
'statusLabel' => 'Preview access only',
|
||||
'expiresAt' => null,
|
||||
'dateLabel' => null,
|
||||
'renewsAutomatically' => false,
|
||||
'source' => 'none',
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->isAcademyAdmin($user)) {
|
||||
return [
|
||||
'signedIn' => true,
|
||||
'tier' => 'admin',
|
||||
'tierLabel' => 'Admin',
|
||||
'hasPaidAccess' => true,
|
||||
'status' => 'staff_access',
|
||||
'statusLabel' => 'Full staff access',
|
||||
'expiresAt' => null,
|
||||
'dateLabel' => null,
|
||||
'renewsAutomatically' => false,
|
||||
'source' => 'admin',
|
||||
];
|
||||
}
|
||||
|
||||
$tier = $this->currentTier($user);
|
||||
$subscription = $this->activeAcademySubscription($user);
|
||||
|
||||
if ($subscription instanceof Subscription) {
|
||||
$trialEndsAt = $subscription->trial_ends_at?->toISOString();
|
||||
$endsAt = $subscription->ends_at?->toISOString();
|
||||
|
||||
if ($subscription->onGracePeriod()) {
|
||||
return [
|
||||
'signedIn' => true,
|
||||
'tier' => $tier,
|
||||
'tierLabel' => $this->tierLabel($tier),
|
||||
'hasPaidAccess' => $tier !== 'free',
|
||||
'status' => 'grace_period',
|
||||
'statusLabel' => 'Cancels soon',
|
||||
'expiresAt' => $endsAt,
|
||||
'dateLabel' => 'Access ends',
|
||||
'renewsAutomatically' => false,
|
||||
'source' => 'subscription',
|
||||
];
|
||||
}
|
||||
|
||||
if ($subscription->onTrial()) {
|
||||
return [
|
||||
'signedIn' => true,
|
||||
'tier' => $tier,
|
||||
'tierLabel' => $this->tierLabel($tier),
|
||||
'hasPaidAccess' => $tier !== 'free',
|
||||
'status' => 'trialing',
|
||||
'statusLabel' => 'Trial active',
|
||||
'expiresAt' => $trialEndsAt,
|
||||
'dateLabel' => 'Trial ends',
|
||||
'renewsAutomatically' => ! $subscription->cancelled(),
|
||||
'source' => 'subscription',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'signedIn' => true,
|
||||
'tier' => $tier,
|
||||
'tierLabel' => $this->tierLabel($tier),
|
||||
'hasPaidAccess' => $tier !== 'free',
|
||||
'status' => 'active',
|
||||
'statusLabel' => 'Renews automatically',
|
||||
'expiresAt' => null,
|
||||
'dateLabel' => null,
|
||||
'renewsAutomatically' => true,
|
||||
'source' => 'subscription',
|
||||
];
|
||||
}
|
||||
|
||||
if ($tier !== 'free') {
|
||||
return [
|
||||
'signedIn' => true,
|
||||
'tier' => $tier,
|
||||
'tierLabel' => $this->tierLabel($tier),
|
||||
'hasPaidAccess' => true,
|
||||
'status' => 'active',
|
||||
'statusLabel' => 'Full access active',
|
||||
'expiresAt' => null,
|
||||
'dateLabel' => null,
|
||||
'renewsAutomatically' => false,
|
||||
'source' => 'legacy_role',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'signedIn' => true,
|
||||
'tier' => 'free',
|
||||
'tierLabel' => 'Free',
|
||||
'hasPaidAccess' => false,
|
||||
'status' => 'free',
|
||||
'statusLabel' => 'Free access',
|
||||
'expiresAt' => null,
|
||||
'dateLabel' => null,
|
||||
'renewsAutomatically' => false,
|
||||
'source' => 'none',
|
||||
];
|
||||
}
|
||||
|
||||
public function canAccessLesson(?User $user, AcademyLesson $lesson): bool
|
||||
{
|
||||
return $this->canAccessContent($user, (string) $lesson->access_level);
|
||||
@@ -633,6 +748,16 @@ final class AcademyAccessService
|
||||
};
|
||||
}
|
||||
|
||||
private function tierLabel(string $tier): string
|
||||
{
|
||||
return match ($this->normalizeAccessLevel($tier)) {
|
||||
'admin' => 'Admin',
|
||||
'pro' => 'Pro',
|
||||
'creator' => 'Creator',
|
||||
default => 'Free',
|
||||
};
|
||||
}
|
||||
|
||||
private function isAcademyAdmin(User $user): bool
|
||||
{
|
||||
return $user->hasStaffAccess() || $user->isModerator();
|
||||
|
||||
@@ -42,6 +42,9 @@ final class AcademyAnalyticsContentResolver
|
||||
if (! $contentId) {
|
||||
return match ($contentType) {
|
||||
AcademyAnalyticsContentType::HOME => 'Academy Home',
|
||||
AcademyAnalyticsContentType::PROMPT_LIBRARY => 'Prompt Library',
|
||||
AcademyAnalyticsContentType::PROMPT_POPULAR => 'Popular Prompts',
|
||||
AcademyAnalyticsContentType::PROMPT_PACK_LIBRARY => 'Prompt Pack Library',
|
||||
AcademyAnalyticsContentType::SEARCH => 'Academy Search',
|
||||
AcademyAnalyticsContentType::UPGRADE => 'Academy Upgrade',
|
||||
default => 'Unknown Academy Content',
|
||||
|
||||
@@ -45,7 +45,7 @@ final class AcademyPopularityService
|
||||
public function queryBetween(Carbon $from, Carbon $to): Builder
|
||||
{
|
||||
return AcademyContentMetricDaily::query()
|
||||
->whereBetween('date', [$from->toDateString(), $to->toDateString()]);
|
||||
->whereBetween('date', [$from->copy()->startOfDay(), $to->copy()->endOfDay()]);
|
||||
}
|
||||
|
||||
public function topContent(Carbon $from, Carbon $to, int $limit = 10): Collection
|
||||
|
||||
@@ -32,9 +32,11 @@ class ContentSanitizer
|
||||
public const EMOJI_DENSITY_MAX = 0.40;
|
||||
|
||||
// HTML tags we allow in the final rendered output
|
||||
// Include heading tags so editor-produced headings (h1-h6) are preserved.
|
||||
private const ALLOWED_TAGS = [
|
||||
'p', 'br', 'strong', 'em', 'code', 'pre',
|
||||
'a', 'ul', 'ol', 'li', 'blockquote', 'del',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
];
|
||||
|
||||
// Allowed attributes per tag
|
||||
|
||||
12
app/Services/Enhance/EnhanceProcessor.php
Normal file
12
app/Services/Enhance/EnhanceProcessor.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Enhance;
|
||||
|
||||
use App\Models\EnhanceJob;
|
||||
|
||||
interface EnhanceProcessor
|
||||
{
|
||||
public function process(EnhanceJob $job): EnhanceProcessorResult;
|
||||
}
|
||||
28
app/Services/Enhance/EnhanceProcessorFactory.php
Normal file
28
app/Services/Enhance/EnhanceProcessorFactory.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Enhance;
|
||||
|
||||
use App\Models\EnhanceJob;
|
||||
use App\Services\Enhance\Processors\ExternalWorkerEnhanceProcessor;
|
||||
use App\Services\Enhance\Processors\StubEnhanceProcessor;
|
||||
use RuntimeException;
|
||||
|
||||
final class EnhanceProcessorFactory
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StubEnhanceProcessor $stubProcessor,
|
||||
private readonly ExternalWorkerEnhanceProcessor $externalWorkerProcessor,
|
||||
) {
|
||||
}
|
||||
|
||||
public function make(string $engine): EnhanceProcessor
|
||||
{
|
||||
return match ($engine) {
|
||||
EnhanceJob::ENGINE_STUB => $this->stubProcessor,
|
||||
EnhanceJob::ENGINE_EXTERNAL_WORKER => $this->externalWorkerProcessor,
|
||||
default => throw new RuntimeException('Unknown enhance processor engine.'),
|
||||
};
|
||||
}
|
||||
}
|
||||
19
app/Services/Enhance/EnhanceProcessorResult.php
Normal file
19
app/Services/Enhance/EnhanceProcessorResult.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Enhance;
|
||||
|
||||
final class EnhanceProcessorResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $disk,
|
||||
public readonly string $path,
|
||||
public readonly int $width,
|
||||
public readonly int $height,
|
||||
public readonly int $filesize,
|
||||
public readonly string $mime,
|
||||
public readonly ?array $metadata = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
265
app/Services/Enhance/EnhanceService.php
Normal file
265
app/Services/Enhance/EnhanceService.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Enhance;
|
||||
|
||||
use App\Jobs\Enhance\ProcessEnhanceJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\EnhanceJob;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class EnhanceService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EnhanceValidator $validator,
|
||||
private readonly EnhanceStorageService $storage,
|
||||
) {
|
||||
}
|
||||
|
||||
public function createFromUpload(User $user, UploadedFile $file, array $options): EnhanceJob
|
||||
{
|
||||
$this->assertCreationAllowed($user);
|
||||
$this->assertDailyLimit($user);
|
||||
|
||||
$validated = $this->validator->validateUpload($file, $options);
|
||||
$source = $this->storage->storeUploadedSource($user, $file);
|
||||
|
||||
$job = DB::transaction(function () use ($user, $validated, $source): EnhanceJob {
|
||||
$enhanceJob = EnhanceJob::query()->create($validated + $source + [
|
||||
'user_id' => (int) $user->id,
|
||||
'status' => EnhanceJob::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
$this->queue($enhanceJob);
|
||||
|
||||
return $enhanceJob->fresh();
|
||||
});
|
||||
|
||||
Log::info('enhance.job.created', [
|
||||
'enhance_job_id' => $job->id,
|
||||
'user_id' => $user->id,
|
||||
'type' => 'upload',
|
||||
'engine' => $job->engine,
|
||||
]);
|
||||
|
||||
return $job;
|
||||
}
|
||||
|
||||
public function createFromArtwork(User $user, Artwork $artwork, array $options): EnhanceJob
|
||||
{
|
||||
$this->assertCreationAllowed($user);
|
||||
$this->assertDailyLimit($user);
|
||||
|
||||
$artworkSource = $this->storage->fetchArtworkSource($artwork);
|
||||
$validated = $this->validator->validateBinary(
|
||||
$artworkSource['binary'],
|
||||
$options,
|
||||
(int) ($artwork->file_size ?? strlen((string) $artworkSource['binary'])),
|
||||
);
|
||||
$source = $this->storage->storeSourceBinary($user, (string) $artworkSource['binary'], (string) $artworkSource['extension']);
|
||||
|
||||
$job = DB::transaction(function () use ($user, $artwork, $validated, $source): EnhanceJob {
|
||||
$enhanceJob = EnhanceJob::query()->create($validated + $source + [
|
||||
'user_id' => (int) $user->id,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => EnhanceJob::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
$this->queue($enhanceJob);
|
||||
|
||||
return $enhanceJob->fresh();
|
||||
});
|
||||
|
||||
Log::info('enhance.job.created', [
|
||||
'enhance_job_id' => $job->id,
|
||||
'user_id' => $user->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'type' => 'artwork',
|
||||
'engine' => $job->engine,
|
||||
]);
|
||||
|
||||
return $job;
|
||||
}
|
||||
|
||||
public function retry(EnhanceJob $job): EnhanceJob
|
||||
{
|
||||
Log::info('enhance.retry.started', [
|
||||
'enhance_job_id' => $job->id,
|
||||
'user_id' => $job->user_id,
|
||||
'status' => $job->status,
|
||||
]);
|
||||
|
||||
if (! $job->isFailed()) {
|
||||
throw ValidationException::withMessages([
|
||||
'job' => 'Only failed enhance jobs can be retried.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $this->sourceExists($job)) {
|
||||
Log::warning('enhance.retry.failed_missing_source', [
|
||||
'enhance_job_id' => $job->id,
|
||||
'user_id' => $job->user_id,
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'job' => 'This enhance job can no longer be retried because the original source file was cleaned up.',
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($job): void {
|
||||
$this->storage->deleteGeneratedFiles($job);
|
||||
|
||||
$metadata = is_array($job->metadata) ? $job->metadata : [];
|
||||
$retryCount = max(0, (int) ($metadata['retry_count'] ?? 0)) + 1;
|
||||
|
||||
$job->forceFill([
|
||||
'status' => EnhanceJob::STATUS_QUEUED,
|
||||
'output_disk' => null,
|
||||
'output_path' => null,
|
||||
'output_hash' => null,
|
||||
'output_width' => null,
|
||||
'output_height' => null,
|
||||
'output_filesize' => null,
|
||||
'output_mime' => null,
|
||||
'preview_disk' => null,
|
||||
'preview_path' => null,
|
||||
'processing_seconds' => null,
|
||||
'error_message' => null,
|
||||
'started_at' => null,
|
||||
'finished_at' => null,
|
||||
'queued_at' => now(),
|
||||
'metadata' => array_merge($metadata, [
|
||||
'retry_count' => $retryCount,
|
||||
'last_retried_at' => now()->toIso8601String(),
|
||||
]),
|
||||
])->save();
|
||||
|
||||
ProcessEnhanceJob::dispatch((int) $job->id)->afterCommit();
|
||||
});
|
||||
|
||||
Log::info('enhance.retry.dispatched', [
|
||||
'enhance_job_id' => $job->id,
|
||||
'user_id' => $job->user_id,
|
||||
'retry_count' => (int) (($job->fresh()?->metadata['retry_count'] ?? 0)),
|
||||
]);
|
||||
|
||||
return $job->fresh();
|
||||
}
|
||||
|
||||
public function markFailedByModerator(EnhanceJob $job, User $actor): EnhanceJob
|
||||
{
|
||||
if (! in_array($job->status, [EnhanceJob::STATUS_PENDING, EnhanceJob::STATUS_QUEUED, EnhanceJob::STATUS_PROCESSING], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'job' => 'Only pending, queued, or processing jobs can be marked as failed.',
|
||||
]);
|
||||
}
|
||||
|
||||
$metadata = is_array($job->metadata) ? $job->metadata : [];
|
||||
|
||||
DB::transaction(function () use ($job, $actor, $metadata): void {
|
||||
$job->forceFill([
|
||||
'status' => EnhanceJob::STATUS_FAILED,
|
||||
'error_message' => 'Marked as failed by moderator.',
|
||||
'finished_at' => now(),
|
||||
'processing_seconds' => $job->started_at ? max(0, now()->diffInSeconds($job->started_at)) : $job->processing_seconds,
|
||||
'metadata' => array_merge($metadata, [
|
||||
'moderation' => [
|
||||
'marked_failed_at' => now()->toIso8601String(),
|
||||
'marked_failed_by' => (int) $actor->id,
|
||||
],
|
||||
]),
|
||||
])->save();
|
||||
});
|
||||
|
||||
Log::info('enhance.moderation.mark_failed', [
|
||||
'enhance_job_id' => $job->id,
|
||||
'moderator_id' => $actor->id,
|
||||
]);
|
||||
|
||||
return $job->fresh();
|
||||
}
|
||||
|
||||
public function delete(EnhanceJob $job): void
|
||||
{
|
||||
DB::transaction(function () use ($job): void {
|
||||
$this->storage->deleteFiles($job);
|
||||
$job->delete();
|
||||
});
|
||||
|
||||
Log::info('enhance.job.deleted', [
|
||||
'enhance_job_id' => $job->id,
|
||||
'user_id' => $job->user_id,
|
||||
]);
|
||||
}
|
||||
|
||||
private function assertCreationAllowed(User $user): void
|
||||
{
|
||||
if (method_exists($user, 'hasVerifiedEmail') && ! $user->hasVerifiedEmail()) {
|
||||
throw ValidationException::withMessages([
|
||||
'image' => 'Please verify your email address before using Skinbase Enhance.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertDailyLimit(User $user): void
|
||||
{
|
||||
$limit = max(0, (int) config('enhance.daily_limit', 10));
|
||||
|
||||
if ($limit === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$count = EnhanceJob::query()
|
||||
->where('user_id', (int) $user->id)
|
||||
->whereBetween('created_at', [now()->startOfDay(), now()->endOfDay()])
|
||||
->count();
|
||||
|
||||
if ($count >= $limit) {
|
||||
throw ValidationException::withMessages([
|
||||
'image' => 'You have reached your daily enhance limit. Please try again tomorrow.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function queue(EnhanceJob $job): void
|
||||
{
|
||||
$job->forceFill([
|
||||
'status' => EnhanceJob::STATUS_QUEUED,
|
||||
'queued_at' => now(),
|
||||
])->save();
|
||||
|
||||
ProcessEnhanceJob::dispatch((int) $job->id)->afterCommit();
|
||||
}
|
||||
|
||||
public function frontendConfig(): array
|
||||
{
|
||||
$engine = (string) config('enhance.default_engine', EnhanceJob::ENGINE_STUB);
|
||||
$showStubWarning = (bool) config('enhance.stub.show_warning', true) && $engine === EnhanceJob::ENGINE_STUB;
|
||||
|
||||
return [
|
||||
'engine' => $engine,
|
||||
'isStub' => $engine === EnhanceJob::ENGINE_STUB,
|
||||
'showStubWarning' => $showStubWarning,
|
||||
'maxUploadMb' => (int) config('enhance.max_upload_mb', 20),
|
||||
'allowedModes' => array_values((array) config('enhance.allowed_modes', [])),
|
||||
'allowedScales' => array_map('intval', (array) config('enhance.allowed_scales', [])),
|
||||
];
|
||||
}
|
||||
|
||||
private function sourceExists(EnhanceJob $job): bool
|
||||
{
|
||||
$path = ltrim(trim((string) $job->source_path), '/');
|
||||
|
||||
if ($path === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Storage::disk($job->source_disk ?: $this->storage->diskName())->exists($path);
|
||||
}
|
||||
}
|
||||
366
app/Services/Enhance/EnhanceStorageService.php
Normal file
366
app/Services/Enhance/EnhanceStorageService.php
Normal file
@@ -0,0 +1,366 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Enhance;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\EnhanceJob;
|
||||
use App\Models\User;
|
||||
use App\Services\ArtworkOriginalFileLocator;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
|
||||
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
|
||||
final class EnhanceStorageService
|
||||
{
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly ArtworkOriginalFileLocator $artworkOriginalFileLocator,
|
||||
) {
|
||||
try {
|
||||
$this->manager = extension_loaded('gd')
|
||||
? new ImageManager(new GdDriver())
|
||||
: new ImageManager(new ImagickDriver());
|
||||
} catch (\Throwable) {
|
||||
$this->manager = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function diskName(): string
|
||||
{
|
||||
return (string) config('enhance.disk', 'public');
|
||||
}
|
||||
|
||||
public function fetchSourceBinary(EnhanceJob $job): string
|
||||
{
|
||||
$path = trim((string) $job->source_path);
|
||||
|
||||
if ($path === '') {
|
||||
throw new RuntimeException('Enhance source image is missing.');
|
||||
}
|
||||
|
||||
$contents = Storage::disk($job->source_disk ?: $this->diskName())->get($path);
|
||||
|
||||
if (! is_string($contents) || $contents === '') {
|
||||
throw new RuntimeException('Unable to read enhance source image.');
|
||||
}
|
||||
|
||||
return $contents;
|
||||
}
|
||||
|
||||
public function fetchArtworkSource(Artwork $artwork): array
|
||||
{
|
||||
$objectPath = $this->artworkOriginalFileLocator->resolveObjectPath($artwork);
|
||||
|
||||
if ($objectPath === '') {
|
||||
throw new RuntimeException('Artwork source file is unavailable for enhance.');
|
||||
}
|
||||
|
||||
$disk = (string) config('uploads.object_storage.disk', 's3');
|
||||
$contents = Storage::disk($disk)->get($objectPath);
|
||||
|
||||
if (! is_string($contents) || $contents === '') {
|
||||
throw new RuntimeException('Unable to read the original artwork source.');
|
||||
}
|
||||
|
||||
$extension = strtolower(ltrim((string) ($artwork->file_ext ?? pathinfo($objectPath, PATHINFO_EXTENSION)), '.'));
|
||||
$mime = trim(strtolower((string) ($artwork->mime_type ?? '')));
|
||||
|
||||
if ($mime === '') {
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = strtolower((string) $finfo->buffer($contents));
|
||||
}
|
||||
|
||||
return [
|
||||
'disk' => $disk,
|
||||
'path' => $objectPath,
|
||||
'binary' => $contents,
|
||||
'mime' => $mime,
|
||||
'extension' => $extension !== '' ? $extension : $this->extensionFromMime($mime),
|
||||
];
|
||||
}
|
||||
|
||||
public function storeUploadedSource(User $user, UploadedFile $file): array
|
||||
{
|
||||
$path = (string) ($file->getRealPath() ?: $file->getPathname());
|
||||
|
||||
if ($path === '' || ! is_readable($path)) {
|
||||
throw new RuntimeException('Unable to resolve uploaded source path.');
|
||||
}
|
||||
|
||||
$binary = file_get_contents($path);
|
||||
|
||||
if (! is_string($binary) || $binary === '') {
|
||||
throw new RuntimeException('Unable to read uploaded source image.');
|
||||
}
|
||||
|
||||
$extension = strtolower(ltrim((string) ($file->getClientOriginalExtension() ?: $file->extension()), '.'));
|
||||
|
||||
return $this->storeSourceBinary($user, $binary, $extension !== '' ? $extension : 'bin');
|
||||
}
|
||||
|
||||
public function storeSourceBinary(User $user, string $binary, string $extension): array
|
||||
{
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = strtolower((string) $finfo->buffer($binary));
|
||||
$normalizedExtension = $extension !== '' ? $extension : $this->extensionFromMime($mime);
|
||||
$relativePath = $this->buildPath((string) config('enhance.source_prefix', 'enhance/sources'), (int) $user->id, sprintf('%s.%s', Str::uuid()->toString(), $normalizedExtension));
|
||||
|
||||
$this->writeBinary($this->diskName(), $relativePath, $binary, $mime);
|
||||
|
||||
return [
|
||||
'source_disk' => $this->diskName(),
|
||||
'source_path' => $relativePath,
|
||||
'source_hash' => hash('sha256', $binary),
|
||||
];
|
||||
}
|
||||
|
||||
public function putOutputBinary(EnhanceJob $job, string $binary, string $mime, ?string $extension = null): array
|
||||
{
|
||||
$normalizedMime = strtolower(trim($mime));
|
||||
$ext = $extension !== null && $extension !== '' ? strtolower(ltrim($extension, '.')) : $this->extensionFromMime($normalizedMime);
|
||||
$filename = sprintf('%s_x%d.%s', Str::uuid()->toString(), (int) $job->scale, $ext);
|
||||
$relativePath = $this->buildPath((string) config('enhance.output_prefix', 'enhance/outputs'), (int) $job->user_id, $filename);
|
||||
|
||||
$this->writeBinary($this->diskName(), $relativePath, $binary, $normalizedMime);
|
||||
$dimensions = @getimagesizefromstring($binary) ?: [0, 0];
|
||||
|
||||
return [
|
||||
'disk' => $this->diskName(),
|
||||
'path' => $relativePath,
|
||||
'hash' => hash('sha256', $binary),
|
||||
'width' => (int) ($dimensions[0] ?? 0),
|
||||
'height' => (int) ($dimensions[1] ?? 0),
|
||||
'filesize' => strlen($binary),
|
||||
'mime' => $normalizedMime,
|
||||
];
|
||||
}
|
||||
|
||||
public function storePreviewFromBinary(EnhanceJob $job, string $binary): ?array
|
||||
{
|
||||
$previewBinary = $binary;
|
||||
$previewMime = 'image/webp';
|
||||
|
||||
if ($this->manager !== null) {
|
||||
try {
|
||||
$previewBinary = (string) $this->manager
|
||||
->read($binary)
|
||||
->scaleDown(width: 1600, height: 1600)
|
||||
->encode(new WebpEncoder(82));
|
||||
} catch (\Throwable) {
|
||||
$previewMime = strtolower((string) ((new \finfo(FILEINFO_MIME_TYPE))->buffer($binary) ?: 'image/jpeg'));
|
||||
$previewBinary = $binary;
|
||||
}
|
||||
} else {
|
||||
$previewMime = strtolower((string) ((new \finfo(FILEINFO_MIME_TYPE))->buffer($binary) ?: 'image/jpeg'));
|
||||
}
|
||||
|
||||
$extension = $this->extensionFromMime($previewMime);
|
||||
$relativePath = $this->buildPath(
|
||||
(string) config('enhance.preview_prefix', 'enhance/previews'),
|
||||
(int) $job->user_id,
|
||||
sprintf('%s_preview.%s', Str::uuid()->toString(), $extension),
|
||||
);
|
||||
|
||||
$this->writeBinary($this->diskName(), $relativePath, $previewBinary, $previewMime);
|
||||
|
||||
return [
|
||||
'preview_disk' => $this->diskName(),
|
||||
'preview_path' => $relativePath,
|
||||
];
|
||||
}
|
||||
|
||||
public function createPreviewFromStoredOutput(EnhanceJob $job, string $disk, string $path): ?array
|
||||
{
|
||||
$contents = Storage::disk($disk)->get($path);
|
||||
|
||||
if (! is_string($contents) || $contents === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->storePreviewFromBinary($job, $contents);
|
||||
}
|
||||
|
||||
public function deleteFiles(EnhanceJob $job): void
|
||||
{
|
||||
$this->deleteFilesForJob($job);
|
||||
}
|
||||
|
||||
public function deleteGeneratedFiles(EnhanceJob $job): void
|
||||
{
|
||||
foreach ([
|
||||
[$job->output_disk, $job->output_path],
|
||||
[$job->preview_disk, $job->preview_path],
|
||||
] as [$disk, $path]) {
|
||||
$this->safeDelete($disk, $path);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteFilesForJob(EnhanceJob $job): array
|
||||
{
|
||||
$result = [
|
||||
'deleted' => [
|
||||
'source' => false,
|
||||
'output' => false,
|
||||
'preview' => false,
|
||||
],
|
||||
'skipped' => [],
|
||||
'errors' => [],
|
||||
];
|
||||
|
||||
foreach ([
|
||||
'source' => [$job->source_disk, $job->source_path],
|
||||
'output' => [$job->output_disk, $job->output_path],
|
||||
'preview' => [$job->preview_disk, $job->preview_path],
|
||||
] as $key => [$disk, $path]) {
|
||||
try {
|
||||
$deleted = $this->safeDelete($disk, $path);
|
||||
$result['deleted'][$key] = $deleted;
|
||||
|
||||
if (! $deleted && trim((string) $path) !== '') {
|
||||
$result['skipped'][] = $key;
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$result['errors'][$key] = $exception->getMessage();
|
||||
|
||||
Log::warning('enhance.cleanup.file_delete_failed', [
|
||||
'path' => trim((string) $path),
|
||||
'disk' => $disk ?: $this->diskName(),
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function isEnhancePath(?string $path): bool
|
||||
{
|
||||
$trimmedPath = ltrim(trim((string) $path), '/');
|
||||
|
||||
if ($trimmedPath === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->enhancePrefixes() as $prefix) {
|
||||
if ($trimmedPath === $prefix || str_starts_with($trimmedPath, $prefix . '/')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function safeDelete(?string $disk, ?string $path): bool
|
||||
{
|
||||
$trimmedPath = ltrim(trim((string) $path), '/');
|
||||
|
||||
if ($trimmedPath === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->isEnhancePath($trimmedPath)) {
|
||||
Log::warning('enhance.cleanup.file_skipped', [
|
||||
'path' => $trimmedPath,
|
||||
'disk' => $disk ?: $this->diskName(),
|
||||
'reason' => 'outside-enhance-prefixes',
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$targetDisk = $disk ?: $this->diskName();
|
||||
|
||||
if (! Storage::disk($targetDisk)->exists($trimmedPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$deleted = Storage::disk($targetDisk)->delete($trimmedPath);
|
||||
|
||||
if ($deleted) {
|
||||
Log::info('enhance.cleanup.file_deleted', [
|
||||
'path' => $trimmedPath,
|
||||
'disk' => $targetDisk,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Log::warning('enhance.cleanup.file_delete_failed', [
|
||||
'path' => $trimmedPath,
|
||||
'disk' => $targetDisk,
|
||||
'message' => 'Storage delete returned false.',
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function listKnownJobPaths(): array
|
||||
{
|
||||
return EnhanceJob::withTrashed()
|
||||
->get(['source_path', 'output_path', 'preview_path'])
|
||||
->flatMap(fn (EnhanceJob $job): array => array_values(array_filter([
|
||||
ltrim(trim((string) $job->source_path), '/'),
|
||||
ltrim(trim((string) $job->output_path), '/'),
|
||||
ltrim(trim((string) $job->preview_path), '/'),
|
||||
])))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function buildPath(string $prefix, int $userId, string $filename): string
|
||||
{
|
||||
return sprintf(
|
||||
'%s/%d/%s/%s/%s',
|
||||
trim($prefix, '/'),
|
||||
$userId,
|
||||
now()->format('Y'),
|
||||
now()->format('m'),
|
||||
ltrim($filename, '/'),
|
||||
);
|
||||
}
|
||||
|
||||
private function enhancePrefixes(): array
|
||||
{
|
||||
return array_values(array_filter(array_unique(array_map(
|
||||
static fn (string $prefix): string => trim($prefix, '/'),
|
||||
[
|
||||
(string) config('enhance.source_prefix', 'enhance/sources'),
|
||||
(string) config('enhance.output_prefix', 'enhance/outputs'),
|
||||
(string) config('enhance.preview_prefix', 'enhance/previews'),
|
||||
],
|
||||
))));
|
||||
}
|
||||
|
||||
private function extensionFromMime(string $mime): string
|
||||
{
|
||||
return match ($mime) {
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
default => 'bin',
|
||||
};
|
||||
}
|
||||
|
||||
private function writeBinary(string $disk, string $path, string $binary, string $mime): void
|
||||
{
|
||||
$written = Storage::disk($disk)->put($path, $binary, [
|
||||
'visibility' => 'public',
|
||||
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||
'ContentType' => $mime,
|
||||
]);
|
||||
|
||||
if ($written !== true) {
|
||||
throw new RuntimeException('Unable to store enhance image in storage.');
|
||||
}
|
||||
}
|
||||
}
|
||||
128
app/Services/Enhance/EnhanceValidator.php
Normal file
128
app/Services/Enhance/EnhanceValidator.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Enhance;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class EnhanceValidator
|
||||
{
|
||||
public function validateUpload(UploadedFile $file, array $options): array
|
||||
{
|
||||
$path = (string) ($file->getRealPath() ?: $file->getPathname());
|
||||
|
||||
if ($path === '' || ! is_readable($path)) {
|
||||
throw ValidationException::withMessages([
|
||||
'image' => 'Unable to read the uploaded image.',
|
||||
]);
|
||||
}
|
||||
|
||||
$binary = file_get_contents($path);
|
||||
|
||||
if (! is_string($binary) || $binary === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'image' => 'Unable to read the uploaded image.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->validateBinary($binary, $options, (int) ($file->getSize() ?? strlen($binary)));
|
||||
}
|
||||
|
||||
public function validateBinary(string $binary, array $options, ?int $filesize = null): array
|
||||
{
|
||||
$normalized = $this->normalizeOptions($options);
|
||||
$size = $filesize ?? strlen($binary);
|
||||
|
||||
if ($size <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'image' => 'Uploaded image is empty.',
|
||||
]);
|
||||
}
|
||||
|
||||
$maxBytes = (int) config('enhance.max_upload_mb', 20) * 1024 * 1024;
|
||||
if ($maxBytes > 0 && $size > $maxBytes) {
|
||||
throw ValidationException::withMessages([
|
||||
'image' => sprintf('The image may not be greater than %d MB.', (int) config('enhance.max_upload_mb', 20)),
|
||||
]);
|
||||
}
|
||||
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = strtolower((string) $finfo->buffer($binary));
|
||||
|
||||
if (! in_array($mime, (array) config('enhance.allowed_mimes', []), true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'image' => 'Unsupported image format. Upload a JPEG, PNG, or WebP image.',
|
||||
]);
|
||||
}
|
||||
|
||||
$dimensions = @getimagesizefromstring($binary);
|
||||
|
||||
if (! is_array($dimensions) || (int) ($dimensions[0] ?? 0) < 1 || (int) ($dimensions[1] ?? 0) < 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'image' => 'Uploaded file is not a valid image.',
|
||||
]);
|
||||
}
|
||||
|
||||
$width = (int) ($dimensions[0] ?? 0);
|
||||
$height = (int) ($dimensions[1] ?? 0);
|
||||
|
||||
if ($width > (int) config('enhance.max_input_width', 4096)) {
|
||||
throw ValidationException::withMessages([
|
||||
'image' => sprintf('Image width may not exceed %d pixels.', (int) config('enhance.max_input_width', 4096)),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($height > (int) config('enhance.max_input_height', 4096)) {
|
||||
throw ValidationException::withMessages([
|
||||
'image' => sprintf('Image height may not exceed %d pixels.', (int) config('enhance.max_input_height', 4096)),
|
||||
]);
|
||||
}
|
||||
|
||||
return $normalized + [
|
||||
'input_width' => $width,
|
||||
'input_height' => $height,
|
||||
'input_filesize' => $size,
|
||||
'input_mime' => $mime,
|
||||
];
|
||||
}
|
||||
|
||||
public function normalizeOptions(array $options): array
|
||||
{
|
||||
$allowedScales = array_map('intval', (array) config('enhance.allowed_scales', [2, 4]));
|
||||
$allowedModes = array_map('strval', (array) config('enhance.allowed_modes', ['standard', 'artwork', 'photo', 'illustration']));
|
||||
$allowedEngines = [
|
||||
\App\Models\EnhanceJob::ENGINE_STUB,
|
||||
\App\Models\EnhanceJob::ENGINE_EXTERNAL_WORKER,
|
||||
];
|
||||
|
||||
$scale = (int) ($options['scale'] ?? config('enhance.allowed_scales.0', 2));
|
||||
$mode = trim((string) ($options['mode'] ?? 'standard'));
|
||||
$engine = trim((string) ($options['engine'] ?? config('enhance.default_engine', \App\Models\EnhanceJob::ENGINE_STUB)));
|
||||
|
||||
if (! in_array($scale, $allowedScales, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'scale' => 'Please select a supported scale.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array($mode, $allowedModes, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'mode' => 'Please select a supported enhance mode.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array($engine, $allowedEngines, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'engine' => 'Please select a supported enhance engine.',
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'scale' => $scale,
|
||||
'mode' => $mode,
|
||||
'engine' => $engine,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Enhance\Processors;
|
||||
|
||||
use App\Models\EnhanceJob;
|
||||
use App\Services\Enhance\EnhanceProcessor;
|
||||
use App\Services\Enhance\EnhanceProcessorResult;
|
||||
use App\Services\Enhance\EnhanceStorageService;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class ExternalWorkerEnhanceProcessor implements EnhanceProcessor
|
||||
{
|
||||
private const SAFE_WORKER_ERRORS = [
|
||||
'Worker is unavailable.',
|
||||
'Worker token is missing.',
|
||||
'Worker rejected the image.',
|
||||
'Worker returned an invalid response.',
|
||||
'The upscaled output exceeded the maximum allowed size.',
|
||||
'The source file could not be downloaded by the worker.',
|
||||
'Upscale engine is not available. Check model files and worker installation.',
|
||||
'The enhance worker timed out while processing this image.',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly EnhanceStorageService $storage,
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(EnhanceJob $job): EnhanceProcessorResult
|
||||
{
|
||||
$workerUrl = trim((string) config('enhance.external_worker.url', ''));
|
||||
|
||||
if ($workerUrl === '') {
|
||||
throw new RuntimeException('Worker URL is missing.');
|
||||
}
|
||||
|
||||
$token = trim((string) config('enhance.external_worker.token', ''));
|
||||
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('Worker token is missing.');
|
||||
}
|
||||
|
||||
$timeout = max(1, (int) config('enhance.external_worker.timeout', 300));
|
||||
$sourceUrl = $this->sourceUrlForWorker($job);
|
||||
|
||||
try {
|
||||
$response = $this->http($timeout)
|
||||
->post($this->workerEndpoint($workerUrl, '/v1/upscale'), [
|
||||
'job_id' => (int) $job->id,
|
||||
'source_url' => $sourceUrl,
|
||||
'scale' => (int) $job->scale,
|
||||
'mode' => (string) $job->mode,
|
||||
'output_format' => 'webp',
|
||||
]);
|
||||
} catch (ConnectionException $exception) {
|
||||
throw $this->wrapHttpException($exception, $job, 'upscale');
|
||||
}
|
||||
|
||||
$payload = $this->decodeWorkerPayload($response);
|
||||
[$binary, $cleanupFilename] = $this->resolveWorkerOutputBinary($payload, $workerUrl, $token, $timeout, $job);
|
||||
$validated = $this->validateOutputBinary($binary);
|
||||
$stored = $this->storage->putOutputBinary($job, $binary, $validated['mime']);
|
||||
|
||||
if ($cleanupFilename !== null) {
|
||||
$this->deleteWorkerResult($workerUrl, $cleanupFilename, $token, $timeout, $job);
|
||||
}
|
||||
|
||||
$metadata = is_array($payload['metadata'] ?? null) ? $payload['metadata'] : [];
|
||||
$metadata['source_transport'] = str_contains($sourceUrl, '/internal/enhance/source/') ? 'signed-route' : 'temporary-url';
|
||||
|
||||
return new EnhanceProcessorResult(
|
||||
disk: $stored['disk'],
|
||||
path: $stored['path'],
|
||||
width: (int) $validated['width'],
|
||||
height: (int) $validated['height'],
|
||||
filesize: (int) $validated['filesize'],
|
||||
mime: (string) $validated['mime'],
|
||||
metadata: $metadata,
|
||||
);
|
||||
}
|
||||
|
||||
private function http(int $timeout): PendingRequest
|
||||
{
|
||||
return Http::timeout($timeout)
|
||||
->acceptJson()
|
||||
->asJson()
|
||||
->withToken((string) config('enhance.external_worker.token'));
|
||||
}
|
||||
|
||||
private function decodeWorkerPayload(Response $response): array
|
||||
{
|
||||
if (! $response->successful()) {
|
||||
$payload = $response->json();
|
||||
|
||||
throw new RuntimeException(
|
||||
$response->status() >= 500
|
||||
? 'Worker is unavailable.'
|
||||
: $this->normalizeWorkerError(is_array($payload) ? ($payload['error'] ?? null) : null, 'Worker rejected the image.'),
|
||||
);
|
||||
}
|
||||
|
||||
$payload = $response->json();
|
||||
|
||||
if (! is_array($payload) || ! ($payload['success'] ?? false)) {
|
||||
throw new RuntimeException(
|
||||
$this->normalizeWorkerError(is_array($payload) ? ($payload['error'] ?? null) : null, 'Worker returned an invalid response.'),
|
||||
);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function resolveWorkerOutputBinary(array $payload, string $workerUrl, string $token, int $timeout, EnhanceJob $job): array
|
||||
{
|
||||
$base64 = trim((string) ($payload['output_base64'] ?? ''));
|
||||
|
||||
if ($base64 !== '') {
|
||||
$binary = base64_decode($base64, true);
|
||||
|
||||
if (! is_string($binary) || $binary === '') {
|
||||
throw new RuntimeException('Worker returned an invalid response.');
|
||||
}
|
||||
|
||||
return [$binary, null];
|
||||
}
|
||||
|
||||
$outputUrl = trim((string) ($payload['output_url'] ?? ''));
|
||||
|
||||
if ($outputUrl === '') {
|
||||
throw new RuntimeException('Worker returned an invalid response.');
|
||||
}
|
||||
|
||||
$safeOutputUrl = $this->normalizeWorkerOutputUrl($workerUrl, $outputUrl);
|
||||
|
||||
try {
|
||||
$outputResponse = Http::timeout($timeout)
|
||||
->withToken($token)
|
||||
->get($safeOutputUrl);
|
||||
} catch (ConnectionException $exception) {
|
||||
throw $this->wrapHttpException($exception, $job, 'download');
|
||||
}
|
||||
|
||||
if (! $outputResponse->successful()) {
|
||||
throw new RuntimeException('Worker returned an invalid response.');
|
||||
}
|
||||
|
||||
$binary = $outputResponse->body();
|
||||
|
||||
if ($binary === '') {
|
||||
throw new RuntimeException('Worker returned an invalid response.');
|
||||
}
|
||||
|
||||
$path = trim((string) parse_url($safeOutputUrl, PHP_URL_PATH));
|
||||
$filename = basename($path);
|
||||
|
||||
return [$binary, $filename !== '' ? $filename : null];
|
||||
}
|
||||
|
||||
private function validateOutputBinary(string $binary): array
|
||||
{
|
||||
$maxBytes = max(1, (int) config('enhance.external_worker.max_download_mb', 60)) * 1024 * 1024;
|
||||
|
||||
if (strlen($binary) > $maxBytes) {
|
||||
throw new RuntimeException('The upscaled output exceeded the maximum allowed size.');
|
||||
}
|
||||
|
||||
$dimensions = @getimagesizefromstring($binary);
|
||||
|
||||
if (! is_array($dimensions)) {
|
||||
throw new RuntimeException('Worker returned an invalid response.');
|
||||
}
|
||||
|
||||
$width = (int) ($dimensions[0] ?? 0);
|
||||
$height = (int) ($dimensions[1] ?? 0);
|
||||
$maxWidth = max(1, (int) config('enhance.max_output_width', 8192));
|
||||
$maxHeight = max(1, (int) config('enhance.max_output_height', 8192));
|
||||
|
||||
if ($width < 1 || $height < 1 || $width > $maxWidth || $height > $maxHeight) {
|
||||
throw new RuntimeException('Worker returned an invalid response.');
|
||||
}
|
||||
|
||||
$mime = strtolower((string) ((new \finfo(FILEINFO_MIME_TYPE))->buffer($binary) ?: ''));
|
||||
|
||||
if (! in_array($mime, (array) config('enhance.allowed_mimes', []), true)) {
|
||||
throw new RuntimeException('Worker returned an invalid response.');
|
||||
}
|
||||
|
||||
return [
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'filesize' => strlen($binary),
|
||||
'mime' => $mime,
|
||||
];
|
||||
}
|
||||
|
||||
private function sourceUrlForWorker(EnhanceJob $job): string
|
||||
{
|
||||
$disk = Storage::disk($job->source_disk ?: $this->storage->diskName());
|
||||
$path = ltrim(trim((string) $job->source_path), '/');
|
||||
|
||||
if ($path === '') {
|
||||
throw new RuntimeException('The source file could not be downloaded by the worker.');
|
||||
}
|
||||
|
||||
try {
|
||||
if (method_exists($disk, 'providesTemporaryUrls') && $disk->providesTemporaryUrls()) {
|
||||
return $disk->temporaryUrl($path, now()->addMinutes(15));
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
return URL::temporarySignedRoute(
|
||||
'enhance.source.download',
|
||||
now()->addMinutes(15),
|
||||
['enhanceJob' => $job->id],
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeWorkerOutputUrl(string $workerUrl, string $outputUrl): string
|
||||
{
|
||||
if (str_starts_with($outputUrl, '/')) {
|
||||
return rtrim($workerUrl, '/') . $outputUrl;
|
||||
}
|
||||
|
||||
$workerParts = parse_url($workerUrl);
|
||||
$outputParts = parse_url($outputUrl);
|
||||
|
||||
if (! is_array($workerParts) || ! is_array($outputParts)) {
|
||||
throw new RuntimeException('Worker returned an invalid response.');
|
||||
}
|
||||
|
||||
$sameHost = ($workerParts['scheme'] ?? null) === ($outputParts['scheme'] ?? null)
|
||||
&& ($workerParts['host'] ?? null) === ($outputParts['host'] ?? null)
|
||||
&& (($workerParts['port'] ?? null) === ($outputParts['port'] ?? null));
|
||||
|
||||
if (! $sameHost) {
|
||||
throw new RuntimeException('Worker returned an invalid response.');
|
||||
}
|
||||
|
||||
return $outputUrl;
|
||||
}
|
||||
|
||||
private function deleteWorkerResult(string $workerUrl, string $filename, string $token, int $timeout, EnhanceJob $job): void
|
||||
{
|
||||
$safeFilename = basename($filename);
|
||||
|
||||
if ($safeFilename === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Http::timeout(min($timeout, 30))
|
||||
->acceptJson()
|
||||
->withToken($token)
|
||||
->delete($this->workerEndpoint($workerUrl, '/v1/results/' . rawurlencode($safeFilename)));
|
||||
} catch (ConnectionException $exception) {
|
||||
Log::warning('enhance.external_worker.cleanup_failed', [
|
||||
'enhance_job_id' => $job->id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function workerEndpoint(string $workerUrl, string $path): string
|
||||
{
|
||||
return rtrim($workerUrl, '/') . $path;
|
||||
}
|
||||
|
||||
private function normalizeWorkerError(mixed $error, string $fallback): string
|
||||
{
|
||||
$message = trim((string) $error);
|
||||
|
||||
if (in_array($message, self::SAFE_WORKER_ERRORS, true)) {
|
||||
return $message;
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
private function wrapHttpException(ConnectionException $exception, EnhanceJob $job, string $stage): RuntimeException
|
||||
{
|
||||
$message = str_contains(strtolower($exception->getMessage()), 'timed out')
|
||||
? 'The enhance worker timed out while processing this image.'
|
||||
: 'Worker is unavailable.';
|
||||
|
||||
Log::warning('enhance.external_worker.http_failed', [
|
||||
'enhance_job_id' => $job->id,
|
||||
'stage' => $stage,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return new RuntimeException($message, 0, $exception);
|
||||
}
|
||||
}
|
||||
75
app/Services/Enhance/Processors/StubEnhanceProcessor.php
Normal file
75
app/Services/Enhance/Processors/StubEnhanceProcessor.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Enhance\Processors;
|
||||
|
||||
use App\Models\EnhanceJob;
|
||||
use App\Services\Enhance\EnhanceProcessor;
|
||||
use App\Services\Enhance\EnhanceProcessorResult;
|
||||
use App\Services\Enhance\EnhanceStorageService;
|
||||
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
|
||||
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
|
||||
use Intervention\Image\Encoders\WebpEncoder;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
final class StubEnhanceProcessor implements EnhanceProcessor
|
||||
{
|
||||
private ?ImageManager $manager = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly EnhanceStorageService $storage,
|
||||
) {
|
||||
try {
|
||||
$this->manager = extension_loaded('gd')
|
||||
? new ImageManager(new GdDriver())
|
||||
: new ImageManager(new ImagickDriver());
|
||||
} catch (\Throwable) {
|
||||
$this->manager = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function process(EnhanceJob $job): EnhanceProcessorResult
|
||||
{
|
||||
$sourceBinary = $this->storage->fetchSourceBinary($job);
|
||||
$outputBinary = $sourceBinary;
|
||||
$outputMime = (string) ($job->input_mime ?: 'image/jpeg');
|
||||
$scale = max(1, (int) $job->scale);
|
||||
$metadata = [
|
||||
'stub' => true,
|
||||
'engine' => EnhanceJob::ENGINE_STUB,
|
||||
'requested_scale' => $scale,
|
||||
];
|
||||
|
||||
if ($this->manager !== null) {
|
||||
try {
|
||||
$image = $this->manager->read($sourceBinary);
|
||||
$targetWidth = max((int) $image->width(), (int) $image->width() * $scale);
|
||||
$targetHeight = max((int) $image->height(), (int) $image->height() * $scale);
|
||||
$outputBinary = (string) $image
|
||||
->resize($targetWidth, $targetHeight)
|
||||
->encode(new WebpEncoder(88));
|
||||
$outputMime = 'image/webp';
|
||||
$metadata['actual_scale'] = $scale;
|
||||
} catch (\Throwable) {
|
||||
$metadata['actual_scale'] = 1;
|
||||
$metadata['fallback'] = 'source-copy';
|
||||
}
|
||||
} else {
|
||||
$metadata['actual_scale'] = 1;
|
||||
$metadata['fallback'] = 'source-copy';
|
||||
}
|
||||
|
||||
$stored = $this->storage->putOutputBinary($job, $outputBinary, $outputMime);
|
||||
|
||||
return new EnhanceProcessorResult(
|
||||
disk: $stored['disk'],
|
||||
path: $stored['path'],
|
||||
width: (int) $stored['width'],
|
||||
height: (int) $stored['height'],
|
||||
filesize: (int) $stored['filesize'],
|
||||
mime: (string) $stored['mime'],
|
||||
metadata: $metadata,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ final class NewsCoverImageService
|
||||
'size_bytes' => strlen($masterEncoded),
|
||||
'mobile_url' => NewsCoverImage::variantUrl($path, 'mobile'),
|
||||
'desktop_url' => NewsCoverImage::variantUrl($path, 'desktop'),
|
||||
'large_url' => NewsCoverImage::variantUrl($path, 'large'),
|
||||
'srcset' => NewsCoverImage::srcset($path),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ use cPad\Plugins\News\Models\NewsArticle;
|
||||
use cPad\Plugins\News\Models\NewsArticleRelation;
|
||||
use cPad\Plugins\News\Models\NewsCategory;
|
||||
use cPad\Plugins\News\Models\NewsTag;
|
||||
use cPad\Plugins\News\Services\NewsArticleService;
|
||||
|
||||
final class NewsService
|
||||
{
|
||||
@@ -39,6 +40,7 @@ final class NewsService
|
||||
public const RELATION_CHALLENGE = 'challenge';
|
||||
public const RELATION_EVENT = 'event';
|
||||
public const RELATION_USER = 'user';
|
||||
public const RELATION_SOURCE = 'source';
|
||||
|
||||
public const RELATION_LABELS = [
|
||||
self::RELATION_GROUP => 'Group',
|
||||
@@ -49,10 +51,15 @@ final class NewsService
|
||||
self::RELATION_CHALLENGE => 'Challenge',
|
||||
self::RELATION_EVENT => 'Event',
|
||||
self::RELATION_USER => 'Profile',
|
||||
self::RELATION_SOURCE => 'Source',
|
||||
];
|
||||
|
||||
private ?bool $artworkStatsViewsColumnExists = null;
|
||||
|
||||
public function __construct(private readonly NewsArticleService $articleService)
|
||||
{
|
||||
}
|
||||
|
||||
public function articleTypeOptions(): array
|
||||
{
|
||||
return \collect(NewsArticle::TYPE_LABELS)
|
||||
@@ -224,12 +231,22 @@ final class NewsService
|
||||
'og_description' => (string) ($article->og_description ?? ''),
|
||||
'og_image' => (string) ($article->og_image ?? ''),
|
||||
'relations' => $article->relatedEntities
|
||||
->map(fn (NewsArticleRelation $relation): array => [
|
||||
'entity_type' => (string) $relation->entity_type,
|
||||
'entity_id' => (int) $relation->entity_id,
|
||||
->map(function (NewsArticleRelation $relation) use ($viewer): array {
|
||||
$entityType = (string) $relation->entity_type;
|
||||
$externalUrl = $entityType === self::RELATION_SOURCE
|
||||
? (string) ($relation->external_url ?? '')
|
||||
: '';
|
||||
|
||||
return [
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityType === self::RELATION_SOURCE ? '' : (int) $relation->entity_id,
|
||||
'external_url' => $externalUrl,
|
||||
'context_label' => (string) ($relation->context_label ?? ''),
|
||||
'preview' => $this->resolveEntityPreview((string) $relation->entity_type, (int) $relation->entity_id, $viewer),
|
||||
])
|
||||
'preview' => $entityType === self::RELATION_SOURCE
|
||||
? $this->resolveSourcePreview($externalUrl, (string) ($relation->context_label ?? ''))
|
||||
: $this->resolveEntityPreview($entityType, (int) $relation->entity_id, $viewer),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
@@ -263,6 +280,8 @@ final class NewsService
|
||||
'published_at' => $article->published_at ?? \now(),
|
||||
])->save();
|
||||
|
||||
$this->articleService->createForumThread($article);
|
||||
|
||||
$this->invalidatePublicCache();
|
||||
|
||||
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
|
||||
@@ -312,6 +331,7 @@ final class NewsService
|
||||
self::RELATION_CHALLENGE => $this->searchChallenges($query, $viewer),
|
||||
self::RELATION_EVENT => $this->searchEvents($query, $viewer),
|
||||
self::RELATION_USER => $this->searchUsers($query),
|
||||
self::RELATION_SOURCE => [],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
@@ -321,7 +341,15 @@ final class NewsService
|
||||
$article->loadMissing('relatedEntities');
|
||||
|
||||
return $article->relatedEntities
|
||||
->map(fn (NewsArticleRelation $relation): ?array => $this->resolveEntityPreview((string) $relation->entity_type, (int) $relation->entity_id, $viewer, (string) ($relation->context_label ?? '')))
|
||||
->map(function (NewsArticleRelation $relation) use ($viewer): ?array {
|
||||
$entityType = (string) $relation->entity_type;
|
||||
|
||||
if ($entityType === self::RELATION_SOURCE) {
|
||||
return $this->resolveSourcePreview((string) ($relation->external_url ?? ''), (string) ($relation->context_label ?? ''));
|
||||
}
|
||||
|
||||
return $this->resolveEntityPreview($entityType, (int) $relation->entity_id, $viewer, (string) ($relation->context_label ?? ''));
|
||||
})
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
@@ -380,6 +408,7 @@ final class NewsService
|
||||
public function invalidatePublicCache(): void
|
||||
{
|
||||
Cache::forever(self::PUBLIC_CACHE_VERSION_KEY, $this->publicCacheVersion() + 1);
|
||||
Cache::forget('news.rss.feed');
|
||||
}
|
||||
|
||||
public function syncRelations(NewsArticle $article, array $relations): void
|
||||
@@ -387,20 +416,32 @@ final class NewsService
|
||||
$normalized = \collect($relations)
|
||||
->map(function (array $relation): ?array {
|
||||
$entityType = trim(Str::lower((string) ($relation['entity_type'] ?? '')));
|
||||
$entityId = (int) ($relation['entity_id'] ?? 0);
|
||||
$externalUrl = $entityType === self::RELATION_SOURCE
|
||||
? $this->normalizeExternalRelationUrl($relation['external_url'] ?? $relation['entity_id'] ?? null)
|
||||
: null;
|
||||
$entityId = $entityType === self::RELATION_SOURCE ? null : (int) ($relation['entity_id'] ?? 0);
|
||||
|
||||
if (! array_key_exists($entityType, self::RELATION_LABELS) || $entityId < 1) {
|
||||
if (! array_key_exists($entityType, self::RELATION_LABELS)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($entityType === self::RELATION_SOURCE && $externalUrl === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($entityType !== self::RELATION_SOURCE && $entityId < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'external_url' => $externalUrl,
|
||||
'context_label' => Str::limit(trim((string) ($relation['context_label'] ?? '')), 120, ''),
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->unique(fn (array $relation): string => $relation['entity_type'] . ':' . $relation['entity_id'])
|
||||
->unique(fn (array $relation): string => $relation['entity_type'] . ':' . ($relation['entity_type'] === self::RELATION_SOURCE ? ($relation['external_url'] ?? '') : $relation['entity_id']))
|
||||
->values();
|
||||
|
||||
$article->relatedEntities()->delete();
|
||||
@@ -409,6 +450,7 @@ final class NewsService
|
||||
$article->relatedEntities()->create([
|
||||
'entity_type' => $relation['entity_type'],
|
||||
'entity_id' => $relation['entity_id'],
|
||||
'external_url' => $relation['external_url'],
|
||||
'context_label' => $relation['context_label'] !== '' ? $relation['context_label'] : null,
|
||||
'sort_order' => $index,
|
||||
]);
|
||||
@@ -808,6 +850,34 @@ final class NewsService
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveSourcePreview(string $externalUrl, string $contextLabel): ?array
|
||||
{
|
||||
$normalizedUrl = $this->normalizeExternalRelationUrl($externalUrl);
|
||||
|
||||
if ($normalizedUrl === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host = \parse_url($normalizedUrl, PHP_URL_HOST);
|
||||
$host = \is_string($host) ? preg_replace('/^www\./i', '', $host) : null;
|
||||
|
||||
return [
|
||||
'id' => $normalizedUrl,
|
||||
'entity_type' => self::RELATION_SOURCE,
|
||||
'entity_label' => self::RELATION_LABELS[self::RELATION_SOURCE],
|
||||
'title' => $host ?: 'External source',
|
||||
'subtitle' => 'Reference link',
|
||||
'description' => Str::limit($normalizedUrl, 140),
|
||||
'url' => $normalizedUrl,
|
||||
'image' => null,
|
||||
'avatar' => null,
|
||||
'context_label' => $contextLabel !== '' ? $contextLabel : 'Source link',
|
||||
'meta' => array_values(array_filter([
|
||||
$host,
|
||||
])),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveGroupPreview(int $entityId, ?User $viewer, string $contextLabel): ?array
|
||||
{
|
||||
$group = Group::query()->with('owner')->find($entityId);
|
||||
@@ -1017,4 +1087,23 @@ final class NewsService
|
||||
'meta' => [],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeExternalRelationUrl(mixed $value): ?string
|
||||
{
|
||||
$url = trim((string) ($value ?? ''));
|
||||
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^\[[^\]]+\]\((https?:\/\/[^)]+)\)$/i', $url, $matches) === 1) {
|
||||
$url = trim((string) ($matches[1] ?? ''));
|
||||
}
|
||||
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Str::limit($url, 2048, '');
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ namespace App\Support\AcademyAnalytics;
|
||||
final class AcademyAnalyticsContentType
|
||||
{
|
||||
public const HOME = 'academy_home';
|
||||
public const PROMPT_LIBRARY = 'academy_prompt_library';
|
||||
public const PROMPT_POPULAR = 'academy_prompt_popular';
|
||||
public const PROMPT_PACK_LIBRARY = 'academy_prompt_pack_library';
|
||||
public const PROMPT = 'academy_prompt';
|
||||
public const LESSON = 'academy_lesson';
|
||||
public const COURSE = 'academy_course';
|
||||
@@ -22,6 +25,9 @@ final class AcademyAnalyticsContentType
|
||||
{
|
||||
return [
|
||||
self::HOME,
|
||||
self::PROMPT_LIBRARY,
|
||||
self::PROMPT_POPULAR,
|
||||
self::PROMPT_PACK_LIBRARY,
|
||||
self::PROMPT,
|
||||
self::LESSON,
|
||||
self::COURSE,
|
||||
|
||||
24
app/Support/ArtworkDescriptionContentValidator.php
Normal file
24
app/Support/ArtworkDescriptionContentValidator.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Services\ContentSanitizer;
|
||||
|
||||
final class ArtworkDescriptionContentValidator
|
||||
{
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function errors(null|string $value): array
|
||||
{
|
||||
$normalized = trim((string) ($value ?? ''));
|
||||
|
||||
if ($normalized === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ContentSanitizer::validate($normalized);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,11 @@ final class NewsCoverImage
|
||||
'quality' => 76,
|
||||
'suffix' => 'desktop',
|
||||
],
|
||||
'large' => [
|
||||
'width' => 1280,
|
||||
'quality' => 80,
|
||||
'suffix' => 'large',
|
||||
],
|
||||
];
|
||||
|
||||
public static function isManagedPath(?string $path): bool
|
||||
|
||||
@@ -29,13 +29,12 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
]);
|
||||
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'chat_post',
|
||||
'chat_post/*',
|
||||
'api/art/*/view',
|
||||
'stripe/*',
|
||||
]);
|
||||
|
||||
$middleware->web(append: [
|
||||
\App\Http\Middleware\SecurityHeaders::class,
|
||||
\App\Http\Middleware\RedirectLegacyProfileSubdomain::class,
|
||||
\App\Http\Middleware\TrackOnlineVisitor::class,
|
||||
\App\Http\Middleware\UpdateLastVisit::class,
|
||||
|
||||
@@ -2096,6 +2096,9 @@
|
||||
"resources/js/Pages/Collection/SavedCollections.jsx": [],
|
||||
"resources/js/Pages/Community/CommunityActivityPage.jsx": [],
|
||||
"resources/js/Pages/Community/LatestCommentsPage.jsx": [],
|
||||
"resources/js/Pages/Enhance/Create.jsx": [],
|
||||
"resources/js/Pages/Enhance/Index.jsx": [],
|
||||
"resources/js/Pages/Enhance/Show.jsx": [],
|
||||
"resources/js/Pages/Feed/FollowingFeed.jsx": [],
|
||||
"resources/js/Pages/Feed/HashtagFeed.jsx": [],
|
||||
"resources/js/Pages/Feed/SavedFeed.jsx": [],
|
||||
@@ -2142,6 +2145,8 @@
|
||||
"resources/js/Pages/Messages/Index.jsx": [],
|
||||
"resources/js/Pages/Moderation/AiBiographyAdmin.jsx": [],
|
||||
"resources/js/Pages/Moderation/ArtworkMaturityQueue.jsx": [],
|
||||
"resources/js/Pages/Moderation/Enhance/Index.jsx": [],
|
||||
"resources/js/Pages/Moderation/Enhance/Show.jsx": [],
|
||||
"resources/js/Pages/Moderation/WorldWebStoriesIndex.jsx": [],
|
||||
"resources/js/Pages/Moderation/WorldWebStoryEditor.jsx": [],
|
||||
"resources/js/Pages/News/NewsComments.jsx": [],
|
||||
@@ -2275,6 +2280,9 @@
|
||||
"resources/js/components/docs/FaqSearchInput.jsx": [],
|
||||
"resources/js/components/docs/QuickstartChecklist.jsx": [],
|
||||
"resources/js/components/docs/QuickstartNextSteps.jsx": [],
|
||||
"resources/js/components/enhance/BeforeAfterSlider.jsx": [],
|
||||
"resources/js/components/enhance/EnhanceStatusBadge.jsx": [],
|
||||
"resources/js/components/enhance/EnhanceStubWarning.jsx": [],
|
||||
"resources/js/components/forum/AuthorBadge.jsx": [],
|
||||
"resources/js/components/forum/Breadcrumbs.jsx": [],
|
||||
"resources/js/components/forum/CategoryCard.jsx": [],
|
||||
@@ -2377,6 +2385,7 @@
|
||||
"resources/js/components/upload/ScreenshotUploader.jsx": [],
|
||||
"resources/js/components/upload/StudioStatusBar.jsx": [],
|
||||
"resources/js/components/upload/UploadActions.jsx": [],
|
||||
"resources/js/components/upload/UploadDescriptionEditor.jsx": [],
|
||||
"resources/js/components/upload/UploadDropzone.jsx": [],
|
||||
"resources/js/components/upload/UploadOverlay.jsx": [],
|
||||
"resources/js/components/upload/UploadSidebar.jsx": [],
|
||||
@@ -2457,7 +2466,9 @@
|
||||
"resources/js/lib/useNavContext.js": [],
|
||||
"resources/js/lib/worldAnalytics.js": [],
|
||||
"resources/js/ssr.jsx": [],
|
||||
"resources/js/utils/contentValidation.js": [],
|
||||
"resources/js/utils/emojiFlood.js": [],
|
||||
"resources/js/utils/enhanceFormatting.js": [],
|
||||
"resources/js/utils/flagUrl.js": [],
|
||||
"resources/js/utils/scheduleCountdown.js": [],
|
||||
"resources/js/utils/studioEvents.js": []
|
||||
|
||||
2758
bootstrap/ssr/ssr.js
2758
bootstrap/ssr/ssr.js
File diff suppressed because one or more lines are too long
@@ -10,7 +10,6 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"alexusmai/laravel-file-manager": "*",
|
||||
"composer/installers": "^2.3",
|
||||
"gumlet/php-image-resize": "*",
|
||||
"inertiajs/inertia-laravel": "^1.0",
|
||||
|
||||
145
composer.lock
generated
145
composer.lock
generated
@@ -4,67 +4,8 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "ae4cbbbd3390e2a18df6cb08a6caf6aa",
|
||||
"content-hash": "541533f2d5a6c0c966730bac8f9c2b37",
|
||||
"packages": [
|
||||
{
|
||||
"name": "alexusmai/laravel-file-manager",
|
||||
"version": "3.3.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/alexusmai/laravel-file-manager.git",
|
||||
"reference": "74bebe32d821d19c1c026545af7e4043fe074aba"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"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|^13.0",
|
||||
"league/flysystem": "^3.0",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Alexusmai\\LaravelFileManager\\FileManagerServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Alexusmai\\LaravelFileManager\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Aleksandr Manekin",
|
||||
"email": "alexusmai@gmail.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "File manager for Laravel",
|
||||
"homepage": "https://github.com/alexusami/laravel-file-manager",
|
||||
"keywords": [
|
||||
"file",
|
||||
"laravel",
|
||||
"manager"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/alexusmai/laravel-file-manager/issues",
|
||||
"source": "https://github.com/alexusmai/laravel-file-manager/tree/3.3.3"
|
||||
},
|
||||
"time": "2026-05-12T10:06:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
"version": "v1.2.7",
|
||||
@@ -1921,90 +1862,6 @@
|
||||
],
|
||||
"time": "2026-05-01T08:20:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "intervention/image-laravel",
|
||||
"version": "1.5.9",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Intervention/image-laravel.git",
|
||||
"reference": "a760b041e5133fd81509414f4955c93ffefb4a7b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Intervention/image-laravel/zipball/a760b041e5133fd81509414f4955c93ffefb4a7b",
|
||||
"reference": "a760b041e5133fd81509414f4955c93ffefb4a7b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/http": "^8|^9|^10|^11|^12|^13",
|
||||
"illuminate/routing": "^8|^9|^10|^11|^12|^13",
|
||||
"illuminate/support": "^8|^9|^10|^11|^12|^13",
|
||||
"intervention/image": "^3.11",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-fileinfo": "*",
|
||||
"orchestra/testbench": "^8.18 || ^9.9 || ^10.6",
|
||||
"phpunit/phpunit": "^10.0 || ^11.0 || ^12.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Image": "Intervention\\Image\\Laravel\\Facades\\Image"
|
||||
},
|
||||
"providers": [
|
||||
"Intervention\\Image\\Laravel\\ServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Intervention\\Image\\Laravel\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Oliver Vogel",
|
||||
"email": "oliver@intervention.io",
|
||||
"homepage": "https://intervention.io/"
|
||||
}
|
||||
],
|
||||
"description": "Laravel Integration of Intervention Image",
|
||||
"homepage": "https://image.intervention.io/",
|
||||
"keywords": [
|
||||
"gd",
|
||||
"image",
|
||||
"imagick",
|
||||
"laravel",
|
||||
"resize",
|
||||
"thumbnail",
|
||||
"watermark"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Intervention/image-laravel/issues",
|
||||
"source": "https://github.com/Intervention/image-laravel/tree/1.5.9"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://paypal.me/interventionio",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/Intervention",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://ko-fi.com/interventionphp",
|
||||
"type": "ko_fi"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-24T15:10:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "jaybizzle/crawler-detect",
|
||||
"version": "v1.3.11",
|
||||
|
||||
60
config/enhance.php
Normal file
60
config/enhance.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'disk' => env('ENHANCE_DISK', env('FILESYSTEM_DISK', 'public')),
|
||||
|
||||
'source_prefix' => env('ENHANCE_SOURCE_PREFIX', 'enhance/sources'),
|
||||
'output_prefix' => env('ENHANCE_OUTPUT_PREFIX', 'enhance/outputs'),
|
||||
'preview_prefix' => env('ENHANCE_PREVIEW_PREFIX', 'enhance/previews'),
|
||||
|
||||
'default_engine' => env('ENHANCE_ENGINE', 'stub'),
|
||||
|
||||
'max_upload_mb' => (int) env('ENHANCE_MAX_UPLOAD_MB', 20),
|
||||
'max_input_width' => (int) env('ENHANCE_MAX_INPUT_WIDTH', 4096),
|
||||
'max_input_height' => (int) env('ENHANCE_MAX_INPUT_HEIGHT', 4096),
|
||||
'max_output_width' => (int) env('ENHANCE_MAX_OUTPUT_WIDTH', 8192),
|
||||
'max_output_height' => (int) env('ENHANCE_MAX_OUTPUT_HEIGHT', 8192),
|
||||
|
||||
'allowed_mimes' => [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
],
|
||||
|
||||
'allowed_modes' => [
|
||||
'standard',
|
||||
'artwork',
|
||||
'photo',
|
||||
'illustration',
|
||||
],
|
||||
|
||||
'allowed_scales' => [2, 4],
|
||||
|
||||
'daily_limit' => (int) env('ENHANCE_DAILY_LIMIT', 10),
|
||||
'queue' => env('ENHANCE_QUEUE', 'default'),
|
||||
|
||||
'lifecycle' => [
|
||||
'completed_expires_after_days' => (int) env('ENHANCE_COMPLETED_EXPIRES_AFTER_DAYS', 30),
|
||||
'failed_expires_after_days' => (int) env('ENHANCE_FAILED_EXPIRES_AFTER_DAYS', 7),
|
||||
'deleted_file_grace_days' => (int) env('ENHANCE_DELETED_FILE_GRACE_DAYS', 1),
|
||||
'cleanup_chunk_size' => (int) env('ENHANCE_CLEANUP_CHUNK_SIZE', 100),
|
||||
],
|
||||
|
||||
'health' => [
|
||||
'stuck_processing_after_minutes' => (int) env('ENHANCE_STUCK_PROCESSING_AFTER_MINUTES', 30),
|
||||
'stuck_queued_after_minutes' => (int) env('ENHANCE_STUCK_QUEUED_AFTER_MINUTES', 60),
|
||||
],
|
||||
|
||||
'stub' => [
|
||||
'show_warning' => filter_var(env('ENHANCE_STUB_SHOW_WARNING', true), FILTER_VALIDATE_BOOL),
|
||||
],
|
||||
|
||||
'external_worker' => [
|
||||
'url' => env('ENHANCE_WORKER_URL'),
|
||||
'timeout' => (int) env('ENHANCE_WORKER_TIMEOUT', 300),
|
||||
'token' => env('ENHANCE_WORKER_TOKEN'),
|
||||
'max_download_mb' => (int) env('ENHANCE_WORKER_MAX_DOWNLOAD_MB', 60),
|
||||
],
|
||||
];
|
||||
@@ -1,166 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Alexusmai\LaravelFileManager\Services\ConfigService\DefaultConfigRepository;
|
||||
use Alexusmai\LaravelFileManager\Services\ACLService\ConfigACLRepository;
|
||||
|
||||
return [
|
||||
|
||||
/**
|
||||
* Set Config repository
|
||||
*
|
||||
* Default - DefaultConfigRepository get config from this file
|
||||
*/
|
||||
'configRepository' => DefaultConfigRepository::class,
|
||||
|
||||
/**
|
||||
* ACL rules repository
|
||||
*
|
||||
* Default - ConfigACLRepository (see rules in - aclRules)
|
||||
*/
|
||||
'aclRepository' => ConfigACLRepository::class,
|
||||
|
||||
//********* Default configuration for DefaultConfigRepository **************
|
||||
|
||||
/**
|
||||
* LFM Route prefix
|
||||
* !!! WARNING - if you change it, you should compile frontend with new prefix(baseUrl) !!!
|
||||
*/
|
||||
'routePrefix' => 'file-manager',
|
||||
|
||||
/**
|
||||
* List of disk names that you want to use
|
||||
* (from config/filesystems)
|
||||
*/
|
||||
'diskList' => ['public'],
|
||||
|
||||
/**
|
||||
* Default disk for left manager
|
||||
*
|
||||
* null - auto select the first disk in the disk list
|
||||
*/
|
||||
'leftDisk' => null,
|
||||
|
||||
/**
|
||||
* Default disk for right manager
|
||||
*
|
||||
* null - auto select the first disk in the disk list
|
||||
*/
|
||||
'rightDisk' => null,
|
||||
|
||||
/**
|
||||
* Default path for left manager
|
||||
*
|
||||
* null - root directory
|
||||
*/
|
||||
'leftPath' => null,
|
||||
|
||||
/**
|
||||
* Default path for right manager
|
||||
*
|
||||
* null - root directory
|
||||
*/
|
||||
'rightPath' => null,
|
||||
|
||||
/**
|
||||
* File manager modules configuration
|
||||
*
|
||||
* 1 - only one file manager window
|
||||
* 2 - one file manager window with directories tree module
|
||||
* 3 - two file manager windows
|
||||
*/
|
||||
'windowsConfig' => 2,
|
||||
|
||||
/**
|
||||
* File upload - Max file size in KB
|
||||
*
|
||||
* null - no restrictions
|
||||
*/
|
||||
'maxUploadFileSize' => null,
|
||||
|
||||
/**
|
||||
* File upload - Allow these file types
|
||||
*
|
||||
* [] - no restrictions
|
||||
*/
|
||||
'allowFileTypes' => [],
|
||||
|
||||
/**
|
||||
* Show / Hide system files and folders
|
||||
*/
|
||||
'hiddenFiles' => true,
|
||||
|
||||
/***************************************************************************
|
||||
* Middleware
|
||||
*
|
||||
* Add your middleware name to array -> ['web', 'auth', 'admin']
|
||||
* !!!! RESTRICT ACCESS FOR NON ADMIN USERS !!!!
|
||||
*/
|
||||
'middleware' => ['web'],
|
||||
|
||||
/***************************************************************************
|
||||
* ACL mechanism ON/OFF
|
||||
*
|
||||
* default - false(OFF)
|
||||
*/
|
||||
'acl' => false,
|
||||
|
||||
/**
|
||||
* Hide files and folders from file-manager if user doesn't have access
|
||||
*
|
||||
* ACL access level = 0
|
||||
*/
|
||||
'aclHideFromFM' => true,
|
||||
|
||||
/**
|
||||
* ACL strategy
|
||||
*
|
||||
* blacklist - Allow everything(access - 2 - r/w) that is not forbidden by the ACL rules list
|
||||
*
|
||||
* whitelist - Deny anything(access - 0 - deny), that not allowed by the ACL rules list
|
||||
*/
|
||||
'aclStrategy' => 'blacklist',
|
||||
|
||||
/**
|
||||
* ACL Rules cache
|
||||
*
|
||||
* null or value in minutes
|
||||
*/
|
||||
'aclRulesCache' => null,
|
||||
|
||||
//********* Default configuration for DefaultConfigRepository END **********
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* ACL rules list - used for default ACL repository (ConfigACLRepository)
|
||||
*
|
||||
* 1 it's user ID
|
||||
* null - for not authenticated user
|
||||
*
|
||||
* 'disk' => 'disk-name'
|
||||
*
|
||||
* 'path' => 'folder-name'
|
||||
* 'path' => 'folder1*' - select folder1, folder12, folder1/sub-folder, ...
|
||||
* 'path' => 'folder2/*' - select folder2/sub-folder,... but not select folder2 !!!
|
||||
* 'path' => 'folder-name/file-name.jpg'
|
||||
* 'path' => 'folder-name/*.jpg'
|
||||
*
|
||||
* * - wildcard
|
||||
*
|
||||
* access: 0 - deny, 1 - read, 2 - read/write
|
||||
*/
|
||||
'aclRules' => [
|
||||
null => [
|
||||
//['disk' => 'public', 'path' => '/', 'access' => 2],
|
||||
],
|
||||
1 => [
|
||||
//['disk' => 'public', 'path' => 'images/arch*.jpg', 'access' => 2],
|
||||
//['disk' => 'public', 'path' => 'files/*', 'access' => 1],
|
||||
],
|
||||
],
|
||||
|
||||
/**
|
||||
* Enable slugification of filenames of uploaded files.
|
||||
*
|
||||
*/
|
||||
'slugifyNames' => false,
|
||||
];
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('enhance_jobs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('artwork_id')->nullable()->constrained('artworks')->nullOnDelete();
|
||||
|
||||
$table->string('status', 32)->default('pending');
|
||||
$table->string('engine', 64)->default('stub');
|
||||
$table->string('mode', 32)->default('standard');
|
||||
$table->unsignedTinyInteger('scale')->default(2);
|
||||
|
||||
$table->string('source_disk', 64)->nullable();
|
||||
$table->string('source_path')->nullable();
|
||||
$table->string('source_hash', 128)->nullable();
|
||||
|
||||
$table->unsignedInteger('input_width')->nullable();
|
||||
$table->unsignedInteger('input_height')->nullable();
|
||||
$table->unsignedBigInteger('input_filesize')->nullable();
|
||||
$table->string('input_mime', 128)->nullable();
|
||||
|
||||
$table->string('output_disk', 64)->nullable();
|
||||
$table->string('output_path')->nullable();
|
||||
$table->string('output_hash', 128)->nullable();
|
||||
|
||||
$table->unsignedInteger('output_width')->nullable();
|
||||
$table->unsignedInteger('output_height')->nullable();
|
||||
$table->unsignedBigInteger('output_filesize')->nullable();
|
||||
$table->string('output_mime', 128)->nullable();
|
||||
|
||||
$table->string('preview_disk', 64)->nullable();
|
||||
$table->string('preview_path')->nullable();
|
||||
|
||||
$table->unsignedInteger('processing_seconds')->nullable();
|
||||
$table->text('error_message')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
|
||||
$table->timestamp('queued_at')->nullable();
|
||||
$table->timestamp('started_at')->nullable();
|
||||
$table->timestamp('finished_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['user_id', 'status']);
|
||||
$table->index(['status', 'created_at']);
|
||||
$table->index(['artwork_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('enhance_jobs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Add a composite unique index on (article_id, ip) to news_views so that
|
||||
* duplicate view inserts at the DB level are impossible even if the session
|
||||
* guard is bypassed (e.g. server restart mid-request).
|
||||
*
|
||||
* A separate unique index on (article_id, user_id) is added for logged-in
|
||||
* users, skipping NULL user_ids so guest records don't conflict.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('news_views')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$driver = DB::getDriverName();
|
||||
|
||||
Schema::table('news_views', function (Blueprint $table) use ($driver): void {
|
||||
// De-duplicate existing rows before adding the index.
|
||||
// Keep only the earliest record per (article_id, ip) pair.
|
||||
if ($driver === 'sqlite') {
|
||||
DB::statement("
|
||||
DELETE FROM news_views
|
||||
WHERE ip IS NOT NULL
|
||||
AND id IN (
|
||||
SELECT nv1.id
|
||||
FROM news_views nv1
|
||||
INNER JOIN news_views nv2
|
||||
ON nv2.article_id = nv1.article_id
|
||||
AND nv2.ip = nv1.ip
|
||||
AND nv2.id < nv1.id
|
||||
WHERE nv1.ip IS NOT NULL
|
||||
)
|
||||
");
|
||||
} else {
|
||||
DB::statement("
|
||||
DELETE nv1 FROM news_views nv1
|
||||
INNER JOIN news_views nv2
|
||||
ON nv2.article_id = nv1.article_id
|
||||
AND nv2.ip = nv1.ip
|
||||
AND nv2.id < nv1.id
|
||||
WHERE nv1.ip IS NOT NULL
|
||||
");
|
||||
}
|
||||
|
||||
// De-duplicate by (article_id, user_id) for authenticated users.
|
||||
if ($driver === 'sqlite') {
|
||||
DB::statement("
|
||||
DELETE FROM news_views
|
||||
WHERE user_id IS NOT NULL
|
||||
AND id IN (
|
||||
SELECT nv1.id
|
||||
FROM news_views nv1
|
||||
INNER JOIN news_views nv2
|
||||
ON nv2.article_id = nv1.article_id
|
||||
AND nv2.user_id = nv1.user_id
|
||||
AND nv2.id < nv1.id
|
||||
WHERE nv1.user_id IS NOT NULL
|
||||
)
|
||||
");
|
||||
} else {
|
||||
DB::statement("
|
||||
DELETE nv1 FROM news_views nv1
|
||||
INNER JOIN news_views nv2
|
||||
ON nv2.article_id = nv1.article_id
|
||||
AND nv2.user_id = nv1.user_id
|
||||
AND nv2.id < nv1.id
|
||||
WHERE nv1.user_id IS NOT NULL
|
||||
");
|
||||
}
|
||||
|
||||
if (! $this->indexExists('news_views', 'news_views_article_ip_unique')) {
|
||||
$table->unique(['article_id', 'ip'], 'news_views_article_ip_unique');
|
||||
}
|
||||
|
||||
if (! $this->indexExists('news_views', 'news_views_article_user_unique')) {
|
||||
$table->unique(['article_id', 'user_id'], 'news_views_article_user_unique');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('news_views')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('news_views', function (Blueprint $table): void {
|
||||
if ($this->indexExists('news_views', 'news_views_article_ip_unique')) {
|
||||
$table->dropUnique('news_views_article_ip_unique');
|
||||
}
|
||||
|
||||
if ($this->indexExists('news_views', 'news_views_article_user_unique')) {
|
||||
$table->dropUnique('news_views_article_user_unique');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function indexExists(string $table, string $indexName): bool
|
||||
{
|
||||
if (DB::getDriverName() === 'sqlite') {
|
||||
return collect(DB::select("PRAGMA index_list('{$table}')"))
|
||||
->contains(static fn (object $row): bool => ($row->name ?? null) === $indexName);
|
||||
}
|
||||
|
||||
$indexes = DB::select("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]);
|
||||
|
||||
return count($indexes) > 0;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('news_article_relations')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('news_article_relations', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('news_article_relations', 'external_url')) {
|
||||
$table->string('external_url', 2048)->nullable()->after('entity_id');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('news_article_relations', function (Blueprint $table): void {
|
||||
$table->unsignedBigInteger('entity_id')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('news_article_relations')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('news_article_relations')
|
||||
->whereNotNull('external_url')
|
||||
->delete();
|
||||
|
||||
Schema::table('news_article_relations', function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('news_article_relations', 'external_url')) {
|
||||
$table->dropColumn('external_url');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('news_article_relations', function (Blueprint $table): void {
|
||||
$table->unsignedBigInteger('entity_id')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
580
docs/enhance-setup.md
Normal file
580
docs/enhance-setup.md
Normal file
@@ -0,0 +1,580 @@
|
||||
Skinbase Enhance Setup Guide
|
||||
===========================
|
||||
|
||||
This guide explains how to set up, enable, verify, and operate the Skinbase Enhance module end to end.
|
||||
|
||||
Use this document when you need to:
|
||||
|
||||
- enable Enhance in a local environment
|
||||
- switch from stub mode to the external worker
|
||||
- run the worker in Pillow mode or Real-ESRGAN mode
|
||||
- configure queues, cleanup, and health checks
|
||||
- understand what the module stores and how it behaves in production
|
||||
|
||||
What the module does
|
||||
--------------------
|
||||
|
||||
Enhance accepts an uploaded or selected source image, creates an Enhance job, processes that job through the configured engine, stores the generated output on the Enhance storage disk, and keeps the original source file untouched.
|
||||
|
||||
Current supported engines on the Laravel side:
|
||||
|
||||
- `ENHANCE_ENGINE=stub`
|
||||
- `ENHANCE_ENGINE=external_worker`
|
||||
|
||||
Current supported worker engines:
|
||||
|
||||
- `WORKER_ENGINE=pillow`
|
||||
- `WORKER_ENGINE=realesrgan-ncnn`
|
||||
- `WORKER_ENGINE=realesrgan`
|
||||
|
||||
`WORKER_ENGINE=realesrgan` currently aliases to `realesrgan-ncnn`.
|
||||
|
||||
Architecture
|
||||
------------
|
||||
|
||||
The Enhance module is split into two layers.
|
||||
|
||||
Laravel application:
|
||||
|
||||
- accepts the Enhance request
|
||||
- validates allowed file types, dimensions, scales, and modes
|
||||
- stores job records
|
||||
- dispatches the job to the queue
|
||||
- owns permanent storage for Enhance sources and outputs
|
||||
- exposes moderation, cleanup, and health commands
|
||||
|
||||
Optional external worker:
|
||||
|
||||
- downloads the copied Enhance source image
|
||||
- upscales it with Pillow or Real-ESRGAN
|
||||
- exposes a temporary internal result URL
|
||||
- deletes its temporary result after Laravel confirms download
|
||||
|
||||
Laravel remains the source of truth. The worker is temporary processing only.
|
||||
|
||||
Default behavior and limits
|
||||
---------------------------
|
||||
|
||||
Default config values from [config/enhance.php](config/enhance.php):
|
||||
|
||||
- disk: `ENHANCE_DISK` or the app filesystem default
|
||||
- source prefix: `enhance/sources`
|
||||
- output prefix: `enhance/outputs`
|
||||
- preview prefix: `enhance/previews`
|
||||
- default engine: `stub`
|
||||
- allowed MIME types: `image/jpeg`, `image/png`, `image/webp`
|
||||
- allowed modes: `standard`, `artwork`, `photo`, `illustration`
|
||||
- allowed scales: `2`, `4`
|
||||
- daily limit: `10`
|
||||
- default queue: `default`
|
||||
|
||||
Lifecycle defaults:
|
||||
|
||||
- completed jobs expire after `30` days
|
||||
- failed jobs expire after `7` days
|
||||
- deleted job files get a `1` day grace period
|
||||
|
||||
Health defaults:
|
||||
|
||||
- processing jobs are considered stuck after `30` minutes
|
||||
- queued jobs are considered stale after `60` minutes
|
||||
|
||||
Prerequisites
|
||||
-------------
|
||||
|
||||
Laravel requirements:
|
||||
|
||||
- the application boots normally
|
||||
- database migrations are current
|
||||
- the configured filesystem disk is writable
|
||||
- a queue worker or Horizon is running for the queue you assign to Enhance
|
||||
- Laravel scheduler is enabled if you want automatic cleanup
|
||||
|
||||
Worker requirements for `external_worker` mode:
|
||||
|
||||
- Docker or a Python runtime for `services/enhance-worker`
|
||||
- network access from Laravel to the worker URL
|
||||
- a shared bearer token between Laravel and the worker
|
||||
- for Real-ESRGAN mode, the `realesrgan-ncnn-vulkan` binary and model files
|
||||
|
||||
Setup paths
|
||||
-----------
|
||||
|
||||
There are three practical ways to run Enhance.
|
||||
|
||||
1. Stub mode
|
||||
|
||||
- safest local starting point
|
||||
- exercises the Laravel flow without a real AI runtime
|
||||
- no worker needed
|
||||
|
||||
2. External worker with Pillow
|
||||
|
||||
- good for local integration testing and CI-like validation
|
||||
- real HTTP worker contract
|
||||
- deterministic fallback upscale path
|
||||
- no Real-ESRGAN runtime files required
|
||||
|
||||
3. External worker with Real-ESRGAN
|
||||
|
||||
- production-oriented path
|
||||
- uses the `realesrgan-ncnn-vulkan` CLI runtime
|
||||
- requires runtime files and a verified worker host
|
||||
|
||||
Laravel setup
|
||||
-------------
|
||||
|
||||
Minimum env for stub mode:
|
||||
|
||||
```env
|
||||
ENHANCE_ENGINE=stub
|
||||
ENHANCE_QUEUE=default
|
||||
```
|
||||
|
||||
Recommended env for external worker mode:
|
||||
|
||||
```env
|
||||
ENHANCE_ENGINE=external_worker
|
||||
ENHANCE_QUEUE=enhance
|
||||
ENHANCE_WORKER_URL=http://127.0.0.1:8095
|
||||
ENHANCE_WORKER_TIMEOUT=900
|
||||
ENHANCE_WORKER_TOKEN=change-this-token
|
||||
ENHANCE_WORKER_MAX_DOWNLOAD_MB=60
|
||||
```
|
||||
|
||||
Optional Laravel env keys you may tune:
|
||||
|
||||
```env
|
||||
ENHANCE_DISK=public
|
||||
ENHANCE_SOURCE_PREFIX=enhance/sources
|
||||
ENHANCE_OUTPUT_PREFIX=enhance/outputs
|
||||
ENHANCE_PREVIEW_PREFIX=enhance/previews
|
||||
|
||||
ENHANCE_MAX_UPLOAD_MB=20
|
||||
ENHANCE_MAX_INPUT_WIDTH=4096
|
||||
ENHANCE_MAX_INPUT_HEIGHT=4096
|
||||
ENHANCE_MAX_OUTPUT_WIDTH=8192
|
||||
ENHANCE_MAX_OUTPUT_HEIGHT=8192
|
||||
|
||||
ENHANCE_DAILY_LIMIT=10
|
||||
|
||||
ENHANCE_COMPLETED_EXPIRES_AFTER_DAYS=30
|
||||
ENHANCE_FAILED_EXPIRES_AFTER_DAYS=7
|
||||
ENHANCE_DELETED_FILE_GRACE_DAYS=1
|
||||
ENHANCE_CLEANUP_CHUNK_SIZE=100
|
||||
|
||||
ENHANCE_STUCK_PROCESSING_AFTER_MINUTES=30
|
||||
ENHANCE_STUCK_QUEUED_AFTER_MINUTES=60
|
||||
|
||||
ENHANCE_STUB_SHOW_WARNING=true
|
||||
```
|
||||
|
||||
After changing env:
|
||||
|
||||
```bash
|
||||
php artisan config:clear
|
||||
```
|
||||
|
||||
Queue setup
|
||||
-----------
|
||||
|
||||
Enhance jobs run on `ENHANCE_QUEUE`.
|
||||
|
||||
If you keep the default queue:
|
||||
|
||||
```bash
|
||||
php artisan queue:work --queue=default
|
||||
```
|
||||
|
||||
If you use a dedicated Enhance queue:
|
||||
|
||||
```bash
|
||||
php artisan queue:work --queue=enhance,default
|
||||
```
|
||||
|
||||
If Horizon or workers do not consume the configured queue, Enhance jobs will stay queued.
|
||||
|
||||
Scheduler and cleanup
|
||||
---------------------
|
||||
|
||||
Enhance cleanup is scheduled from [routes/console.php](routes/console.php) and runs:
|
||||
|
||||
```bash
|
||||
php artisan enhance:cleanup --force
|
||||
```
|
||||
|
||||
Useful cleanup and health commands:
|
||||
|
||||
```bash
|
||||
php artisan enhance:health
|
||||
php artisan enhance:health --json
|
||||
php artisan enhance:cleanup --dry-run
|
||||
php artisan enhance:cleanup --force
|
||||
```
|
||||
|
||||
Cleanup only removes files under the configured Enhance prefixes. It does not delete artwork originals or unrelated storage paths.
|
||||
|
||||
Enable stub mode
|
||||
----------------
|
||||
|
||||
Use stub mode first if you want to validate the Laravel module without introducing worker runtime variables.
|
||||
|
||||
1. Set:
|
||||
|
||||
```env
|
||||
ENHANCE_ENGINE=stub
|
||||
ENHANCE_QUEUE=default
|
||||
```
|
||||
|
||||
2. Clear config:
|
||||
|
||||
```bash
|
||||
php artisan config:clear
|
||||
```
|
||||
|
||||
3. Start a queue worker:
|
||||
|
||||
```bash
|
||||
php artisan queue:work --queue=default
|
||||
```
|
||||
|
||||
4. Check health:
|
||||
|
||||
```bash
|
||||
php artisan enhance:health
|
||||
```
|
||||
|
||||
5. Open `/enhance/create`, submit a small image, and verify the job completes.
|
||||
|
||||
Enable external worker mode with Pillow
|
||||
---------------------------------------
|
||||
|
||||
This is the safest real integration path because it exercises the HTTP worker contract without requiring Real-ESRGAN files.
|
||||
|
||||
1. Start the worker:
|
||||
|
||||
```bash
|
||||
cd services/enhance-worker
|
||||
docker compose -f docker-compose.example.yml up --build
|
||||
```
|
||||
|
||||
2. Confirm worker health:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8095/health
|
||||
```
|
||||
|
||||
Expected result:
|
||||
|
||||
- `status: ok`
|
||||
- `engine: pillow`
|
||||
|
||||
3. Set Laravel env:
|
||||
|
||||
```env
|
||||
ENHANCE_ENGINE=external_worker
|
||||
ENHANCE_QUEUE=enhance
|
||||
ENHANCE_WORKER_URL=http://127.0.0.1:8095
|
||||
ENHANCE_WORKER_TIMEOUT=600
|
||||
ENHANCE_WORKER_TOKEN=change-this-token
|
||||
ENHANCE_WORKER_MAX_DOWNLOAD_MB=60
|
||||
```
|
||||
|
||||
4. Clear config and start queue workers:
|
||||
|
||||
```bash
|
||||
php artisan config:clear
|
||||
php artisan queue:work --queue=enhance,default
|
||||
```
|
||||
|
||||
5. Verify the Laravel side:
|
||||
|
||||
```bash
|
||||
php artisan enhance:health
|
||||
php artisan test --filter=EnhanceExternalWorker
|
||||
```
|
||||
|
||||
Enable external worker mode with Real-ESRGAN
|
||||
--------------------------------------------
|
||||
|
||||
This is the production-oriented setup.
|
||||
|
||||
1. Install or mount the runtime files inside [services/enhance-worker](services/enhance-worker):
|
||||
|
||||
```bash
|
||||
cd services/enhance-worker
|
||||
bash scripts/download-realesrgan-ncnn.sh
|
||||
bash scripts/verify-realesrgan.sh
|
||||
```
|
||||
|
||||
Required file locations:
|
||||
|
||||
- binary: `bin/realesrgan-ncnn-vulkan`
|
||||
- models: `models/*.param` and `models/*.bin`
|
||||
|
||||
2. Start the Real-ESRGAN worker:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.realesrgan.example.yml up --build
|
||||
```
|
||||
|
||||
3. Confirm worker health:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8095/health
|
||||
```
|
||||
|
||||
Expected result when ready:
|
||||
|
||||
- `status: ok`
|
||||
- `engine: realesrgan-ncnn`
|
||||
|
||||
Expected result when runtime files are missing or invalid:
|
||||
|
||||
- `status: degraded`
|
||||
|
||||
4. Set Laravel env:
|
||||
|
||||
```env
|
||||
ENHANCE_ENGINE=external_worker
|
||||
ENHANCE_QUEUE=enhance
|
||||
ENHANCE_WORKER_URL=http://127.0.0.1:8095
|
||||
ENHANCE_WORKER_TIMEOUT=900
|
||||
ENHANCE_WORKER_TOKEN=change-this-token
|
||||
ENHANCE_WORKER_MAX_DOWNLOAD_MB=60
|
||||
```
|
||||
|
||||
5. Clear config and start the queue worker:
|
||||
|
||||
```bash
|
||||
php artisan config:clear
|
||||
php artisan queue:work --queue=enhance,default
|
||||
```
|
||||
|
||||
6. Verify through the application by submitting a small image from `/enhance/create`.
|
||||
|
||||
Worker configuration
|
||||
--------------------
|
||||
|
||||
Main worker env values:
|
||||
|
||||
```env
|
||||
WORKER_HOST=0.0.0.0
|
||||
WORKER_PORT=8095
|
||||
WORKER_TOKEN=change-this-token
|
||||
|
||||
WORKER_ENGINE=pillow
|
||||
WORKER_DEVICE=cpu
|
||||
|
||||
WORKER_MAX_UPLOAD_MB=20
|
||||
WORKER_MAX_INPUT_WIDTH=4096
|
||||
WORKER_MAX_INPUT_HEIGHT=4096
|
||||
WORKER_MAX_OUTPUT_WIDTH=8192
|
||||
WORKER_MAX_OUTPUT_HEIGHT=8192
|
||||
|
||||
WORKER_TMP_DIR=/app/storage/tmp
|
||||
WORKER_OUTPUT_DIR=/app/storage/output
|
||||
WORKER_RESULT_TTL_MINUTES=60
|
||||
```
|
||||
|
||||
Real-ESRGAN-specific worker env values:
|
||||
|
||||
```env
|
||||
WORKER_REALESRGAN_BIN=/app/bin/realesrgan-ncnn-vulkan
|
||||
WORKER_REALESRGAN_MODEL_DIR=/app/models
|
||||
WORKER_REALESRGAN_DEFAULT_MODEL=realesrgan-x4plus
|
||||
WORKER_REALESRGAN_ANIME_MODEL=realesrgan-x4plus-anime
|
||||
WORKER_REALESRGAN_TILE=0
|
||||
WORKER_REALESRGAN_TTA=false
|
||||
WORKER_REALESRGAN_VERBOSE=false
|
||||
WORKER_REALESRGAN_TIMEOUT_SECONDS=900
|
||||
WORKER_REALESRGAN_PREPROCESS_MAX_PIXELS=16777216
|
||||
WORKER_REALESRGAN_OUTPUT_EXT=webp
|
||||
WORKER_REALESRGAN_ALLOW_MODEL_FALLBACK=true
|
||||
```
|
||||
|
||||
Compatibility values kept by the worker:
|
||||
|
||||
```env
|
||||
WORKER_MODEL_DIR=/app/app/models
|
||||
WORKER_DEFAULT_MODEL=realesrgan-x4plus
|
||||
```
|
||||
|
||||
Worker behavior
|
||||
---------------
|
||||
|
||||
Worker request contract:
|
||||
|
||||
- endpoint: `POST /v1/upscale`
|
||||
- bearer auth required
|
||||
- accepted output formats: `webp`, `png`, `jpg`
|
||||
- allowed scales: `2`, `4`
|
||||
- allowed modes: `standard`, `artwork`, `photo`, `illustration`
|
||||
|
||||
Health and temp file endpoints:
|
||||
|
||||
- `GET /health`
|
||||
- `GET /v1/results/{filename}`
|
||||
- `DELETE /v1/results/{filename}`
|
||||
|
||||
Mode and scale behavior
|
||||
-----------------------
|
||||
|
||||
Real-ESRGAN mode mapping:
|
||||
|
||||
- `standard` -> default model
|
||||
- `artwork` -> default model
|
||||
- `photo` -> default model
|
||||
- `illustration` -> anime model when available
|
||||
|
||||
Fallback behavior:
|
||||
|
||||
- if the requested model exists, it is used
|
||||
- if it is missing and fallback is enabled, the default model is used
|
||||
- if it is missing and fallback is disabled, processing fails safely
|
||||
|
||||
Scale behavior:
|
||||
|
||||
- `4x` returns native 4x output
|
||||
- `2x` currently runs the 4x model and then downsamples to 2x
|
||||
|
||||
Storage and data flow
|
||||
---------------------
|
||||
|
||||
Laravel stores Enhance files under the configured prefixes:
|
||||
|
||||
- sources under `enhance/sources`
|
||||
- outputs under `enhance/outputs`
|
||||
- previews under `enhance/previews`
|
||||
|
||||
The worker does not permanently store Enhance results.
|
||||
|
||||
When `ENHANCE_ENGINE=external_worker`:
|
||||
|
||||
1. Laravel prepares a copied Enhance source file.
|
||||
2. Laravel sends the worker a temporary URL or a temporary signed internal route.
|
||||
3. The worker downloads the source file.
|
||||
4. The worker processes the image.
|
||||
5. The worker exposes a temporary result URL.
|
||||
6. Laravel downloads, validates, and stores the final output.
|
||||
7. Laravel instructs the worker to delete the temporary result.
|
||||
|
||||
Verification checklist
|
||||
----------------------
|
||||
|
||||
Laravel verification:
|
||||
|
||||
```bash
|
||||
php artisan config:clear
|
||||
php artisan enhance:health
|
||||
php artisan enhance:health --json
|
||||
```
|
||||
|
||||
Worker verification:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8095/health
|
||||
```
|
||||
|
||||
Queue verification:
|
||||
|
||||
```bash
|
||||
php artisan queue:work --queue=enhance,default
|
||||
```
|
||||
|
||||
Application verification:
|
||||
|
||||
1. Open `/enhance/create`.
|
||||
2. Upload or choose a small source image.
|
||||
3. Select `2x` or `4x` and a valid mode.
|
||||
4. Submit the job.
|
||||
5. Confirm the job transitions from queued to completed.
|
||||
6. Confirm output exists on the Enhance disk.
|
||||
7. Confirm the source file and original artwork remain untouched.
|
||||
|
||||
Health states
|
||||
-------------
|
||||
|
||||
Laravel health command helps identify:
|
||||
|
||||
- configured engine
|
||||
- queue usage
|
||||
- stuck jobs
|
||||
- lifecycle status
|
||||
|
||||
Worker `/health` helps identify:
|
||||
|
||||
- current worker engine
|
||||
- maximum input and output limits
|
||||
- Real-ESRGAN binary availability
|
||||
- Real-ESRGAN model directory readiness
|
||||
- available Real-ESRGAN models
|
||||
|
||||
If the worker is in Real-ESRGAN mode and health returns `degraded`, do not treat the runtime as production-ready yet.
|
||||
|
||||
Troubleshooting
|
||||
---------------
|
||||
|
||||
`Worker URL is missing.`
|
||||
|
||||
- set `ENHANCE_WORKER_URL`
|
||||
- clear config
|
||||
|
||||
`Worker token is missing.`
|
||||
|
||||
- set `ENHANCE_WORKER_TOKEN`
|
||||
- make sure the worker uses the same `WORKER_TOKEN`
|
||||
|
||||
`Worker is unavailable.`
|
||||
|
||||
- confirm the worker is reachable
|
||||
- confirm the worker container is running
|
||||
- confirm the URL points to the worker base URL
|
||||
|
||||
`Upscale engine is not available. Check model files and worker installation.`
|
||||
|
||||
- confirm `WORKER_ENGINE=realesrgan-ncnn`
|
||||
- confirm `bin/realesrgan-ncnn-vulkan` exists and is executable
|
||||
- confirm the required `.param` and `.bin` model files exist
|
||||
- run `bash scripts/verify-realesrgan.sh`
|
||||
|
||||
Jobs stay queued
|
||||
|
||||
- confirm queue workers consume `ENHANCE_QUEUE`
|
||||
- if using `enhance`, run workers with `--queue=enhance,default`
|
||||
|
||||
`status: degraded` from worker health
|
||||
|
||||
- verify binary and model directory paths
|
||||
- verify runtime files are mounted inside the container
|
||||
- fall back to `WORKER_ENGINE=pillow` until the runtime is fixed
|
||||
|
||||
Operations and safety notes
|
||||
---------------------------
|
||||
|
||||
- Keep the worker bound to `127.0.0.1` or a private container network.
|
||||
- Do not expose the worker publicly.
|
||||
- Use a strong shared token.
|
||||
- Keep Enhance on a dedicated queue when load increases.
|
||||
- Keep cleanup enabled so stale outputs and failed files do not accumulate.
|
||||
- Do not commit Real-ESRGAN binary or model weight files unless explicitly approved.
|
||||
- The worker only serves generated files from its own output directory.
|
||||
- The worker rejects unsupported source URLs and unsafe output paths.
|
||||
- Original artwork files are never replaced by the Enhance flow.
|
||||
|
||||
Production rollout recommendation
|
||||
---------------------------------
|
||||
|
||||
Recommended rollout sequence:
|
||||
|
||||
1. Enable `ENHANCE_ENGINE=stub` and verify the Laravel workflow.
|
||||
2. Move to `ENHANCE_ENGINE=external_worker` with `WORKER_ENGINE=pillow`.
|
||||
3. Verify queue, storage, cleanup, and health behavior.
|
||||
4. Install Real-ESRGAN runtime files and switch the worker to `WORKER_ENGINE=realesrgan-ncnn`.
|
||||
5. Confirm worker health is `ok` before calling the runtime production-ready.
|
||||
|
||||
Related docs
|
||||
------------
|
||||
|
||||
- operational notes: [docs/enhance.md](docs/enhance.md)
|
||||
- worker runtime docs: [services/enhance-worker/README.md](services/enhance-worker/README.md)
|
||||
105
docs/enhance.md
Normal file
105
docs/enhance.md
Normal file
@@ -0,0 +1,105 @@
|
||||
Skinbase Enhance
|
||||
================
|
||||
|
||||
Operational notes for the Enhance v1/v1.1 module.
|
||||
|
||||
For full setup, enablement, worker configuration, verification, and production rollout guidance, see [docs/enhance-setup.md](docs/enhance-setup.md).
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
- `ENHANCE_ENGINE=stub` keeps Enhance in preview mode for local and workflow testing.
|
||||
- `ENHANCE_ENGINE=external_worker` uses the prepared external worker adapter boundary.
|
||||
- `ENHANCE_QUEUE=default` is the current safe default.
|
||||
- `ENHANCE_QUEUE=enhance` is supported when you want a dedicated queue later.
|
||||
- If you switch to `ENHANCE_QUEUE=enhance`, Horizon or any worker process must explicitly consume the `enhance` queue in production.
|
||||
|
||||
Helpful commands
|
||||
----------------
|
||||
|
||||
```bash
|
||||
php artisan enhance:health
|
||||
php artisan enhance:health --json
|
||||
php artisan enhance:cleanup --dry-run
|
||||
php artisan enhance:cleanup --force
|
||||
php artisan queue:work --queue=enhance,default
|
||||
```
|
||||
|
||||
Cleanup behavior
|
||||
----------------
|
||||
|
||||
- Enhance cleanup only removes files under the configured Enhance prefixes:
|
||||
- `enhance/sources`
|
||||
- `enhance/outputs`
|
||||
- `enhance/previews`
|
||||
- Cleanup never deletes artwork originals, thumbnails, avatars, or other non-Enhance paths.
|
||||
- Completed jobs can expire automatically via `ENHANCE_COMPLETED_EXPIRES_AFTER_DAYS`.
|
||||
- Failed jobs can have stale files pruned via `ENHANCE_FAILED_EXPIRES_AFTER_DAYS`.
|
||||
- Soft-deleted jobs respect `ENHANCE_DELETED_FILE_GRACE_DAYS` before file cleanup.
|
||||
|
||||
Scheduler
|
||||
---------
|
||||
|
||||
- The app schedules `php artisan enhance:cleanup --force` daily from `routes/console.php`.
|
||||
- If you disable Laravel's scheduler in an environment, run cleanup manually or through external cron.
|
||||
|
||||
Queue and Horizon reminder
|
||||
--------------------------
|
||||
|
||||
- Stub mode still dispatches queued Enhance jobs and exercises the same lifecycle.
|
||||
- If workers only consume `default` and you later move Enhance to a dedicated queue, completed jobs will stall in `queued` until `enhance` is added to the worker queue list.
|
||||
|
||||
External Worker v1
|
||||
------------------
|
||||
|
||||
- Set `ENHANCE_ENGINE=external_worker` to switch Laravel from the stub processor to the HTTP worker integration.
|
||||
- Recommended local Laravel env:
|
||||
|
||||
```env
|
||||
ENHANCE_ENGINE=external_worker
|
||||
ENHANCE_WORKER_URL=http://127.0.0.1:8095
|
||||
ENHANCE_WORKER_TIMEOUT=600
|
||||
ENHANCE_WORKER_TOKEN=change-this-token
|
||||
ENHANCE_WORKER_MAX_DOWNLOAD_MB=60
|
||||
ENHANCE_QUEUE=enhance
|
||||
```
|
||||
|
||||
- Keep the worker bound to `127.0.0.1` or a private container network. Do not expose it publicly.
|
||||
- Laravel sends the worker a short-lived source URL. If the storage disk cannot issue temporary URLs, Laravel falls back to a temporary signed internal route that serves only the copied Enhance source file.
|
||||
- Laravel remains the source of truth: the worker only returns a temporary result, Laravel downloads it, validates it, stores it on the Enhance disk, and then asks the worker to delete the temporary file.
|
||||
- Useful commands after enabling the worker:
|
||||
|
||||
```bash
|
||||
php artisan config:clear
|
||||
php artisan enhance:health
|
||||
php artisan queue:work --queue=enhance,default
|
||||
php artisan test --filter=EnhanceExternalWorker
|
||||
```
|
||||
|
||||
Real-ESRGAN Runtime
|
||||
-------------------
|
||||
|
||||
- Laravel still uses `ENHANCE_ENGINE=external_worker`. It does not need to know whether the worker uses Pillow or Real-ESRGAN internally.
|
||||
- Use `WORKER_ENGINE=pillow` for local development, CI, and fallback operation.
|
||||
- Use `WORKER_ENGINE=realesrgan-ncnn` for the real ncnn-vulkan runtime path.
|
||||
- If worker health reports `status: degraded`, keep Laravel on the stub processor or a Pillow worker until the Real-ESRGAN runtime is verified.
|
||||
- Do not expose the worker port publicly.
|
||||
- Move to the `enhance` Horizon queue only after the worker is healthy and verified in your environment.
|
||||
- Real-ESRGAN runtime files are not committed. Install them locally or in deployment with the worker scripts:
|
||||
|
||||
```bash
|
||||
cd services/enhance-worker
|
||||
bash scripts/download-realesrgan-ncnn.sh
|
||||
bash scripts/verify-realesrgan.sh
|
||||
```
|
||||
|
||||
- Recommended Laravel env when using the real worker:
|
||||
|
||||
```env
|
||||
ENHANCE_ENGINE=external_worker
|
||||
ENHANCE_QUEUE=enhance
|
||||
ENHANCE_WORKER_URL=http://127.0.0.1:8095
|
||||
ENHANCE_WORKER_TIMEOUT=900
|
||||
ENHANCE_WORKER_TOKEN=change-this-token
|
||||
ENHANCE_WORKER_MAX_DOWNLOAD_MB=60
|
||||
```
|
||||
@@ -1,8 +0,0 @@
|
||||
<?php
|
||||
// TEMPORARY — delete after use
|
||||
if (function_exists('opcache_reset')) {
|
||||
opcache_reset();
|
||||
echo "OPcache reset OK — " . date('H:i:s');
|
||||
} else {
|
||||
echo "opcache_reset() not available";
|
||||
}
|
||||
@@ -1583,6 +1583,22 @@
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.academy-lesson-prose :not(pre) > code {
|
||||
display: inline-block;
|
||||
padding: 0.14em 0.46em 0.16em;
|
||||
border: 1px solid rgba(125, 211, 252, 0.18);
|
||||
border-radius: 0.38rem;
|
||||
background: rgba(56, 189, 248, 0.08);
|
||||
color: rgb(186 230 253);
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Fira Code', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.875em;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
vertical-align: baseline;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.academy-lesson-prose pre::after {
|
||||
inset: 3rem 0 auto 0;
|
||||
background: linear-gradient(90deg, rgba(56, 189, 248, 0), rgba(56, 189, 248, 0.26), rgba(56, 189, 248, 0));
|
||||
|
||||
@@ -23,6 +23,7 @@ const buildAdminNavGroups = (isAdmin) => [
|
||||
items: [
|
||||
{ label: 'Stories', href: '/moderation/stories', icon: 'fa-solid fa-feather-pointed' },
|
||||
{ label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images' },
|
||||
{ label: 'Enhance Jobs', href: '/moderation/enhance', icon: 'fa-solid fa-up-right-and-down-left-from-center' },
|
||||
{ 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' },
|
||||
|
||||
@@ -4,8 +4,90 @@ import SeoHead from '../../components/seo/SeoHead'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
import { trackAcademySearchResultClick, trackUpgradeClick, useAcademyPageAnalytics } from '../../lib/academyAnalytics'
|
||||
|
||||
function CourseCard({ course, variant = 'default', analytics = null, searchContext = null, position = null }) {
|
||||
const isFeatured = variant === 'featured'
|
||||
function Breadcrumbs({ items = [] }) {
|
||||
if (!items.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${item.label}-${index}`}>
|
||||
{isLast ? (
|
||||
<span className="text-white/80">{item.label}</span>
|
||||
) : (
|
||||
<Link href={item.href} className="transition hover:text-white">{item.label}</Link>
|
||||
)}
|
||||
{!isLast ? <span className="text-slate-600">/</span> : null}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function formatAccessDate(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = new Date(value)
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(parsed)
|
||||
}
|
||||
|
||||
function academyAccessHeading(access) {
|
||||
switch (access?.status) {
|
||||
case 'staff_access':
|
||||
return 'You currently have full staff access to the Academy.'
|
||||
case 'grace_period':
|
||||
return `${access.tierLabel} access is still active.`
|
||||
case 'trialing':
|
||||
return `${access.tierLabel} trial is active right now.`
|
||||
case 'active':
|
||||
return access?.hasPaidAccess ? `${access.tierLabel} access is active.` : 'Your Academy access is active.'
|
||||
case 'free':
|
||||
return 'You currently have Free access to the Academy.'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function academyAccessMeta(access) {
|
||||
if (!access?.signedIn) {
|
||||
return []
|
||||
}
|
||||
|
||||
const items = [
|
||||
{ label: 'Current tier', value: access?.tierLabel || 'Free' },
|
||||
{ label: 'Status', value: access?.statusLabel || 'Free access' },
|
||||
]
|
||||
|
||||
const formattedDate = formatAccessDate(access?.expiresAt)
|
||||
|
||||
if (formattedDate && access?.dateLabel) {
|
||||
items.push({ label: access.dateLabel, value: formattedDate })
|
||||
} else if (access?.renewsAutomatically) {
|
||||
items.push({ label: 'Billing', value: 'Renews automatically' })
|
||||
} else if (!access?.hasPaidAccess) {
|
||||
items.push({ label: 'Upgrade', value: 'Creator and Pro unlock premium lessons and courses' })
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function CourseCard({ course, analytics = null, searchContext = null, position = null }) {
|
||||
const progress = course?.progress || null
|
||||
const cover = course?.cover_image_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image || ''
|
||||
const trackSearchClick = () => {
|
||||
@@ -29,48 +111,56 @@ function CourseCard({ course, variant = 'default', analytics = null, searchConte
|
||||
data-academy-search-query={searchContext?.query || undefined}
|
||||
data-academy-search-results-count={searchContext?.resultsCount || undefined}
|
||||
data-academy-search-position={position || undefined}
|
||||
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]',
|
||||
].join(' ')}
|
||||
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-amber-200/24 hover:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(10,15,26,0.98))]"
|
||||
>
|
||||
<div className="relative">
|
||||
{cover ? <img src={cover} alt="" aria-hidden="true" className={`w-full object-cover ${isFeatured ? 'h-56' : 'h-44'}`} /> : <div className={`w-full bg-[linear-gradient(135deg,rgba(14,165,233,0.22),rgba(15,23,42,0.92))] ${isFeatured ? 'h-56' : 'h-44'}`} />}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.82))]" />
|
||||
<div className="absolute left-5 top-5 flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100">{course.difficulty}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-200">{course.access_level}</span>
|
||||
<div className="relative aspect-[16/10] overflow-hidden bg-[linear-gradient(135deg,rgba(251,191,36,0.18),rgba(17,24,39,0.94))]">
|
||||
{cover ? <img src={cover} alt="" aria-hidden="true" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.72))]" />
|
||||
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||
{course?.difficulty ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{course.difficulty}</span> : null}
|
||||
{course?.access_level ? <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">{course.access_level}</span> : null}
|
||||
{course.is_featured ? <span className="rounded-full border border-amber-300/20 bg-amber-300/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100">Featured</span> : null}
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 right-4 flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<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">{course?.lessons_count || 0} lessons</span>
|
||||
<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">{course?.estimated_minutes ? `${course.estimated_minutes} min` : 'Flexible pace'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<h2 className={`font-semibold tracking-[-0.05em] text-white transition group-hover:text-sky-100 ${isFeatured ? 'text-3xl' : 'text-2xl'}`}>{course.title}</h2>
|
||||
{course.subtitle ? <p className="mt-2 text-sm font-medium uppercase tracking-[0.18em] text-slate-400">{course.subtitle}</p> : null}
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">{course.excerpt || course.description || 'Structured Academy course.'}</p>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Lessons</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{course.lessons_count || 0}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Duration</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{course.estimated_minutes ? `${course.estimated_minutes} min` : 'Flexible'}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Progress</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{progress ? `${progress.percent}%` : 'Start fresh'}</p>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Academy course</p>
|
||||
{course?.subtitle ? <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">{course.subtitle}</span> : null}
|
||||
</div>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white transition group-hover:text-amber-50">{course.title}</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{course.excerpt || course.description || 'Structured Academy course.'}</p>
|
||||
<p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{progress ? `${progress.percent}% complete` : 'Start fresh'}{course?.access_level ? ` · ${course.access_level}` : ''}</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyCoursesIndex({ seo, title, description, items, featuredCourses = [], filters = {}, pricingUrl, analytics }) {
|
||||
export default function AcademyCoursesIndex({ seo, title, description, items, featuredCourses = [], filters = {}, pricingUrl, lessonsUrl, promptLibraryUrl, academyAccess = null, analytics }) {
|
||||
const flash = usePage().props.flash || {}
|
||||
useAcademyPageAnalytics(analytics)
|
||||
const breadcrumbs = [
|
||||
{ label: 'Academy', href: '/academy' },
|
||||
{ label: 'Courses', href: '/academy/courses' },
|
||||
]
|
||||
const visibleItems = Array.isArray(items?.data) ? items.data : []
|
||||
const totalCourses = Number(items?.total || items?.data?.length || 0)
|
||||
const featuredCount = featuredCourses.length
|
||||
const featuredCourse = featuredCourses.find((course) => course?.cover_image_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image) || featuredCourses[0] || visibleItems[0] || null
|
||||
const featuredCover = featuredCourse?.cover_image_url || featuredCourse?.teaser_image_url || featuredCourse?.cover_image || featuredCourse?.teaser_image || ''
|
||||
const showSignedInAccess = Boolean(academyAccess?.signedIn)
|
||||
const accessHeading = academyAccessHeading(academyAccess)
|
||||
const accessMeta = academyAccessMeta(academyAccess)
|
||||
const useBillingAction = showSignedInAccess && academyAccess?.hasPaidAccess && academyAccess?.billingUrl
|
||||
const primaryActionLabel = useBillingAction ? (academyAccess?.status === 'grace_period' ? 'Renew now' : 'Manage billing') : 'See plans'
|
||||
const primaryActionIcon = useBillingAction ? (academyAccess?.status === 'grace_period' ? 'fa-solid fa-rotate-right' : 'fa-solid fa-sliders') : 'fa-solid fa-arrow-up-right-from-square'
|
||||
const primaryActionHref = useBillingAction ? academyAccess.billingUrl : pricingUrl
|
||||
const searchContext = analytics?.search ? {
|
||||
query: analytics.search.query,
|
||||
normalizedQuery: analytics.search.normalizedQuery,
|
||||
@@ -90,34 +180,137 @@ export default function AcademyCoursesIndex({ seo, title, description, items, fe
|
||||
{ value: 'mixed', label: 'Mixed' },
|
||||
]
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (!useBillingAction) {
|
||||
trackUpgradeClick(analytics, { source: 'academy_courses_index_hero_primary' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<SeoHead seo={seo || {}} title={title} description={description} />
|
||||
|
||||
<div className="mx-auto max-w-[1400px] space-y-6">
|
||||
<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">
|
||||
<div className="flex flex-wrap items-end justify-between gap-6">
|
||||
<div className="max-w-4xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Skinbase AI Academy</p>
|
||||
<h1 className="mt-4 text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl lg:text-6xl">{title}</h1>
|
||||
<p className="mt-5 text-base leading-8 text-slate-300 md:text-lg">{description}</p>
|
||||
<section className="relative overflow-hidden rounded-[40px] border border-amber-200/12 bg-[linear-gradient(155deg,rgba(251,191,36,0.14),rgba(15,23,42,0.96)_36%,rgba(14,165,233,0.14))] p-5 shadow-[0_32px_96px_rgba(2,6,23,0.32)] md:p-6 xl:p-7">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_14%,rgba(253,230,138,0.14),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,24px_24px,24px_24px] opacity-75" />
|
||||
<div className="absolute -left-8 top-12 h-36 w-36 rounded-full bg-amber-300/18 blur-3xl" />
|
||||
<div className="absolute -right-10 bottom-0 h-40 w-40 rounded-full bg-sky-300/14 blur-3xl" />
|
||||
|
||||
<div className="relative grid gap-5 xl:grid-cols-[minmax(0,1.1fr)_360px] xl:items-start">
|
||||
<div className="min-w-0">
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-amber-200/18 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-amber-50/90">Skinbase AI Academy</span>
|
||||
<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-200">Courses</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">{totalCourses} guided paths</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-start justify-between gap-4">
|
||||
<div className="max-w-4xl">
|
||||
<h1 className="max-w-[13ch] text-4xl font-semibold tracking-[-0.05em] text-white md:max-w-[14ch] md:text-[4.2rem] xl:max-w-[15ch] xl:text-[4.5rem]">{title}</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-200/95">{description}</p>
|
||||
</div>
|
||||
<span className="hidden h-20 w-20 shrink-0 items-center justify-center rounded-[26px] border border-white/10 bg-white/[0.04] text-2xl text-amber-50 shadow-[0_18px_40px_rgba(2,6,23,0.22)] md:flex">
|
||||
<i className="fa-solid fa-route" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/35 px-5 py-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100/75">Library</p>
|
||||
<p className="mt-2 text-lg font-semibold text-white">{totalCourses} guided courses</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/35 px-5 py-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100/75">Focus</p>
|
||||
<p className="mt-2 text-lg font-semibold text-white">Sequenced learning + tracked completion</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2.5">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-amber-50/90">Structured progression</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-amber-50/90">Tracked completion</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-amber-50/90">Reusable lesson paths</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link href={lessonsUrl || '/academy/lessons'} className="inline-flex items-center gap-2 rounded-full border border-amber-200/26 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-50 transition hover:border-amber-200/36 hover:bg-amber-300/18">
|
||||
<i className="fa-solid fa-book-open-reader text-xs" />
|
||||
Browse lessons
|
||||
</Link>
|
||||
<Link href={promptLibraryUrl || '/academy/prompts'} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white/90 transition hover:bg-white/[0.1]">
|
||||
<i className="fa-solid fa-wand-magic-sparkles text-xs" />
|
||||
Prompt library
|
||||
</Link>
|
||||
<Link href={primaryActionHref} onClick={handlePrimaryAction} className="inline-flex items-center gap-2 rounded-full border border-sky-200/20 bg-sky-300/10 px-5 py-3 text-sm font-semibold text-sky-50 transition hover:border-sky-200/30 hover:bg-sky-300/16">
|
||||
<i className={`${primaryActionIcon} text-xs`} />
|
||||
{primaryActionLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-4 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm md:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Featured course</p>
|
||||
{featuredCourse?.difficulty ? <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-white/80">{featuredCourse.difficulty}</span> : null}
|
||||
</div>
|
||||
<Link href={featuredCourse?.public_url || '#academy-courses-grid'} className="group mt-4 block overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04] transition hover:border-amber-200/24 hover:bg-white/[0.06]">
|
||||
<div className="relative aspect-[16/10] overflow-hidden bg-[linear-gradient(135deg,rgba(251,191,36,0.18),rgba(17,24,39,0.94))]">
|
||||
{featuredCover ? <img src={featuredCover} alt="" aria-hidden="true" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.78))]" />
|
||||
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||
{featuredCourse?.access_level ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{featuredCourse.access_level}</span> : null}
|
||||
{featuredCourse?.is_featured ? <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">Spotlight</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{featuredCourse?.subtitle || 'Guided learning path'}</p>
|
||||
<h3 className="mt-2 text-lg font-semibold tracking-[-0.03em] text-white transition group-hover:text-amber-50">{featuredCourse?.title || 'Explore courses'}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-300">{featuredCourse?.excerpt || featuredCourse?.description || 'Open a structured Academy course built from reusable lessons.'}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-2">
|
||||
<div className="rounded-[28px] border border-sky-300/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.16),rgba(15,23,42,0.82))] p-4 shadow-[0_18px_45px_rgba(2,6,23,0.2)] md:p-5 lg:p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-sky-300/25 bg-sky-300/12 text-sky-100"><i className="fa-solid fa-crown text-sm" /></span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-100/80">{showSignedInAccess ? 'Your Academy access' : 'Upgrade for full access'}</p>
|
||||
<p className="mt-1 max-w-4xl text-lg font-semibold text-white md:text-[1.85rem] md:leading-[1.45]">{showSignedInAccess ? accessHeading : 'Unlock the full course library, premium lesson paths, and the broader Academy learning track.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{showSignedInAccess ? (
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{accessMeta.map((item) => (
|
||||
<div key={item.label} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 md:px-5 md:py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/70">{item.label}</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white md:text-base">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Link href={primaryActionHref} onClick={handlePrimaryAction} className="inline-flex items-center gap-2 rounded-full border border-sky-200/30 bg-sky-200/15 px-5 py-2.5 text-sm font-semibold text-sky-50 transition hover:bg-sky-200/22">
|
||||
<i className={`${primaryActionIcon} text-xs`} />
|
||||
{primaryActionLabel}
|
||||
</Link>
|
||||
<Link href={lessonsUrl || '/academy/lessons'} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-5 py-2.5 text-sm font-semibold text-white/90 transition hover:bg-white/[0.1]">
|
||||
<i className="fa-solid fa-book-open-reader text-xs" />
|
||||
Browse lessons
|
||||
</Link>
|
||||
</div>
|
||||
{academyAccess?.status === 'grace_period' ? <p className="mt-2 text-xs text-sky-100/75">Opens billing account to restore renewal before access ends.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<Link 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</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{flash.success ? <div className="rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||
{flash.error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||
|
||||
{featuredCourses.length ? (
|
||||
<section className="grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
||||
<CourseCard course={featuredCourses[0]} variant="featured" analytics={analytics} searchContext={searchContext} position={1} />
|
||||
<div className="grid gap-5">
|
||||
{featuredCourses.slice(1, 3).map((course, index) => <CourseCard key={course.id} course={course} analytics={analytics} searchContext={searchContext} position={index + 2} />)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-3 rounded-[30px] border border-white/10 bg-black/20 p-5 md:grid-cols-2">
|
||||
<NovaSelect
|
||||
label="Difficulty"
|
||||
@@ -137,11 +330,11 @@ export default function AcademyCoursesIndex({ seo, title, description, items, fe
|
||||
/>
|
||||
</section>
|
||||
|
||||
{(items?.data || []).length === 0 ? (
|
||||
{visibleItems.length === 0 ? (
|
||||
<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.</section>
|
||||
) : (
|
||||
<section className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.data.map((course, index) => <CourseCard key={course.id} course={course} analytics={analytics} searchContext={searchContext} position={index + 1} />)}
|
||||
<section id="academy-courses-grid" className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{visibleItems.map((course, index) => <CourseCard key={course.id} course={course} analytics={analytics} searchContext={searchContext} position={index + 1} />)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -64,35 +64,35 @@ function LessonChip({ lesson }) {
|
||||
const isCompleted = Boolean(lesson?.completed)
|
||||
const readingMinutes = Number(lesson?.reading_minutes || 0)
|
||||
const ctaLabel = isCompleted ? 'Review lesson' : 'Open lesson'
|
||||
const difficultyLabel = lesson?.difficulty || 'lesson'
|
||||
const accessLabel = lesson?.access_level || 'free'
|
||||
const lessonTypeLabel = lesson?.lesson_type || 'article'
|
||||
const statusLabel = isCompleted ? 'Completed' : lesson?.is_required ? 'Required next' : 'Optional read'
|
||||
const supportCopy = isCompleted ? 'You already finished this lesson.' : lesson?.is_required ? 'Recommended as the next required step in this course.' : 'Optional depth you can take at your own pace.'
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={lesson.course_url || `/academy/lessons/${lesson.slug}`}
|
||||
className={[
|
||||
'group relative overflow-hidden rounded-[32px] border bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.64))] shadow-[0_24px_50px_rgba(2,6,23,0.2)] transition duration-200 hover:-translate-y-1 hover:border-sky-300/30 hover:shadow-[0_28px_70px_rgba(14,165,233,0.12)]',
|
||||
'group relative overflow-hidden rounded-[34px] border bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(15,23,42,0.78))] shadow-[0_24px_54px_rgba(2,6,23,0.22)] transition duration-200 hover:-translate-y-1 hover:border-sky-300/30 hover:shadow-[0_28px_74px_rgba(14,165,233,0.16)]',
|
||||
isCompleted ? 'border-emerald-300/25' : 'border-white/10',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(125,211,252,0.09),transparent_24%),linear-gradient(135deg,transparent,rgba(255,255,255,0.02))] opacity-70 transition duration-200 group-hover:opacity-100" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(125,211,252,0.12),transparent_24%),radial-gradient(circle_at_bottom_left,rgba(251,191,36,0.08),transparent_28%),linear-gradient(135deg,transparent,rgba(255,255,255,0.02))] opacity-80 transition duration-200 group-hover:opacity-100" />
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(125,211,252,0.42),transparent)]" />
|
||||
|
||||
<div className="relative grid gap-0 lg:grid-cols-[172px_minmax(0,1fr)]">
|
||||
<div className="relative border-b border-white/10 bg-slate-950 lg:border-b-0 lg:border-r">
|
||||
<div className="relative grid gap-0 lg:grid-cols-[188px_minmax(0,1fr)]">
|
||||
<div className="relative border-b border-white/10 bg-slate-950/90 lg:border-b-0 lg:border-r lg:border-white/10">
|
||||
{thumbnail ? (
|
||||
<img src={thumbnail} alt="" aria-hidden="true" className="h-40 w-full object-cover lg:h-full" />
|
||||
<img src={thumbnail} alt="" aria-hidden="true" className="h-44 w-full object-cover lg:h-full" />
|
||||
) : (
|
||||
<div className="h-40 w-full bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(15,23,42,0.92))] lg:h-full" />
|
||||
<div className="h-44 w-full bg-[linear-gradient(135deg,rgba(56,189,248,0.2),rgba(15,23,42,0.96))] lg:h-full" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.04),rgba(2,6,23,0.84))]" />
|
||||
<div className="absolute inset-x-3 top-3 flex items-start justify-between gap-3">
|
||||
{lesson.is_required ? (
|
||||
<span className="rounded-full border border-white/10 bg-black/35 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-white/80 backdrop-blur">
|
||||
Required
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.08),rgba(2,6,23,0.42)_42%,rgba(2,6,23,0.9))]" />
|
||||
<div className="absolute inset-x-4 top-4 flex items-start justify-between gap-3">
|
||||
<span className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] backdrop-blur ${lesson.is_required ? 'border-white/10 bg-black/40 text-white/82' : 'border-white/10 bg-black/30 text-white/62'}`}>
|
||||
{lesson.is_required ? 'Required' : 'Optional'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full border border-white/10 bg-black/35 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-white/65 backdrop-blur">
|
||||
Optional
|
||||
</span>
|
||||
)}
|
||||
{isCompleted ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-300/25 bg-emerald-300/12 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100 backdrop-blur">
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
|
||||
@@ -103,50 +103,55 @@ function LessonChip({ lesson }) {
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="absolute inset-x-3 bottom-3 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<div className="absolute inset-x-4 bottom-4 flex items-end justify-between gap-3">
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/30 px-3 py-2 backdrop-blur-sm">
|
||||
{stepLabel ? <p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-sky-100/80">{stepLabel}</p> : null}
|
||||
{stepNumber > 0 ? <p className="mt-1 text-5xl font-semibold tracking-[-0.1em] text-white">{String(stepNumber).padStart(2, '0')}</p> : null}
|
||||
{!stepNumber && lesson.formatted_lesson_number ? <p className="mt-1 text-sm font-semibold uppercase tracking-[0.16em] text-white/80">{lesson.formatted_lesson_number}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 md:p-6">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_200px] xl:items-start">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_240px] xl:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{stepLabel ? <p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-sky-100">{stepLabel}</p> : null}
|
||||
{lesson.formatted_lesson_number ? <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-400">{lesson.formatted_lesson_number}</span> : null}
|
||||
<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-400">{lesson.difficulty || 'lesson'}</span>
|
||||
<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-400">{lesson.access_level || 'free'}</span>
|
||||
<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-400">{difficultyLabel}</span>
|
||||
<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-400">{accessLabel}</span>
|
||||
{readingMinutes > 0 ? <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-400">{readingMinutes} min</span> : null}
|
||||
</div>
|
||||
<h3 className="mt-3 max-w-3xl text-[1.65rem] font-semibold tracking-[-0.05em] text-white transition group-hover:text-sky-100">{lesson.title}</h3>
|
||||
<p className="mt-2 text-sm text-slate-400">{isCompleted ? 'You already finished this lesson.' : 'Follow this step next in the course path.'}</p>
|
||||
<p className="mt-2 text-sm text-slate-400">{supportCopy}</p>
|
||||
|
||||
<div className="mt-4 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<div className="mt-4 rounded-[26px] border border-white/10 bg-[linear-gradient(180deg,rgba(2,6,23,0.36),rgba(2,6,23,0.18))] px-4 py-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
|
||||
<p className="text-sm leading-7 text-slate-300">{lesson.excerpt || lesson.content_preview || 'Open this lesson inside the course.'}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5">{lesson.lesson_type || 'article'}</span>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5">{lessonTypeLabel}</span>
|
||||
{lesson.category_name ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5">{lesson.category_name}</span> : null}
|
||||
<span className="text-slate-500">Course flow</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-col justify-between gap-4 xl:border-l xl:border-white/10 xl:pl-5">
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Status</p>
|
||||
<p className={`mt-2 text-sm font-semibold ${isCompleted ? 'text-emerald-100' : 'text-white'}`}>{isCompleted ? 'Completed' : 'Up next'}</p>
|
||||
<div className="flex h-full flex-col justify-between gap-4 xl:border-l xl:border-white/10 xl:pl-6">
|
||||
<div className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Lesson path</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3 rounded-[18px] border border-white/10 bg-black/20 px-3 py-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Status</span>
|
||||
<span className={`text-sm font-semibold ${isCompleted ? 'text-emerald-100' : 'text-white'}`}>{statusLabel}</span>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Access</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{lesson.access_level || 'Free'}</p>
|
||||
<div className="flex items-center justify-between gap-3 rounded-[18px] border border-white/10 bg-black/20 px-3 py-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Access</span>
|
||||
<span className="text-sm font-semibold text-white">{accessLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 rounded-[18px] border border-white/10 bg-black/20 px-3 py-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Read time</span>
|
||||
<span className="text-sm font-semibold text-white">{readingMinutes > 0 ? `${readingMinutes} min` : 'Quick read'}</span>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Read time</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{readingMinutes > 0 ? `${readingMinutes} min` : 'Quick read'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -170,26 +175,32 @@ function LessonChip({ lesson }) {
|
||||
function SectionBlock({ section, isActive = false }) {
|
||||
if (!section?.is_visible) return null
|
||||
|
||||
const lessonCount = section.lessons?.length || 0
|
||||
const requiredCount = (section.lessons || []).filter((lesson) => lesson?.is_required).length
|
||||
|
||||
return (
|
||||
<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]'}`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<section className={`relative overflow-hidden rounded-[34px] 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_24px_56px_rgba(14,165,233,0.08)]' : 'border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))]'}`}>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(125,211,252,0.08),transparent_28%),linear-gradient(135deg,transparent,rgba(255,255,255,0.015))] opacity-80" />
|
||||
<div className="relative flex flex-wrap items-start justify-between gap-5">
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Course section</p>
|
||||
<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}
|
||||
</span>
|
||||
{requiredCount > 0 ? <span className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{requiredCount} required</span> : null}
|
||||
</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{section.title}</h2>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.05em] text-white md:text-[2rem]">{section.title}</h2>
|
||||
{section.description ? <p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">{section.description}</p> : null}
|
||||
<p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{lessonCount} lessons mapped in this section</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<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</span>
|
||||
<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">{lessonCount} lessons</span>
|
||||
{isActive ? <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</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-6">
|
||||
<div className="relative mt-6 space-y-6">
|
||||
{(section.lessons || []).map((lesson) => (
|
||||
<LessonChip key={lesson.course_lesson_id || lesson.id} lesson={lesson} />
|
||||
))}
|
||||
@@ -202,20 +213,24 @@ export default function AcademyCoursesShow({ seo, course, sections = [], unsecti
|
||||
const flash = usePage().props.flash || {}
|
||||
useAcademyPageAnalytics(analytics)
|
||||
const cover = course?.cover_image_url || course?.cover_image || course?.teaser_image_url || course?.teaser_image || ''
|
||||
const heroBackground = course?.teaser_image_url || course?.teaser_image || course?.cover_image_url || course?.cover_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 visibleSections = sections.filter((section) => section?.is_visible)
|
||||
const totalLessons = Number(course?.lessons_count || (unsectionedLessons.length + visibleSections.reduce((sum, section) => sum + (section.lessons || []).length, 0)))
|
||||
const totalSections = visibleSections.length + (unsectionedLessons.length ? 1 : 0)
|
||||
const estimatedMinutes = course?.estimated_minutes ? `${course.estimated_minutes} min` : 'Flexible pace'
|
||||
|
||||
const sectionJumpItems = useMemo(
|
||||
() => [
|
||||
...(unsectionedLessons.length ? [{ id: 'course-outline-core', label: 'Core lessons', count: unsectionedLessons.length }] : []),
|
||||
...sections
|
||||
.filter((section) => section?.is_visible)
|
||||
...visibleSections
|
||||
.map((section) => ({ id: `section-${section.id}`, label: section.title, count: (section.lessons || []).length })),
|
||||
],
|
||||
[sections, unsectionedLessons],
|
||||
[unsectionedLessons, visibleSections],
|
||||
)
|
||||
|
||||
const [activeJumpId, setActiveJumpId] = useState(sectionJumpItems[0]?.id || null)
|
||||
@@ -316,25 +331,35 @@ export default function AcademyCoursesShow({ seo, course, sections = [], unsecti
|
||||
{flash.success ? <div className="rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||
{flash.error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||
|
||||
<section className="overflow-hidden rounded-[40px] border border-white/10 bg-black/20 shadow-[0_24px_90px_rgba(2,6,23,0.34)]">
|
||||
<div className="grid gap-0 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||||
<div className="relative overflow-hidden p-6 md:p-8 lg:p-10 xl:p-12">
|
||||
{cover ? <img src={cover} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover opacity-[0.18]" /> : null}
|
||||
<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))]" />
|
||||
<section className="relative overflow-hidden rounded-[40px] border border-sky-200/12 bg-[linear-gradient(145deg,rgba(14,165,233,0.16),rgba(15,23,42,0.95)_38%,rgba(251,191,36,0.14))] shadow-[0_24px_90px_rgba(2,6,23,0.34)]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_14%,rgba(125,211,252,0.14),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,24px_24px,24px_24px] opacity-70" />
|
||||
<div className="absolute -left-8 top-10 h-36 w-36 rounded-full bg-sky-300/18 blur-3xl" />
|
||||
<div className="absolute -right-10 bottom-0 h-40 w-40 rounded-full bg-amber-300/14 blur-3xl" />
|
||||
|
||||
<div className="relative grid gap-6 p-5 md:p-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:p-7">
|
||||
<div className="min-w-0">
|
||||
{heroBackground ? <img src={heroBackground} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover opacity-[0.08]" /> : null}
|
||||
<div className="relative z-10 max-w-5xl">
|
||||
<CourseBreadcrumbs items={breadcrumbs} />
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2.5">
|
||||
<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</span>
|
||||
<span className="rounded-full border border-sky-200/18 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-50/90">Skinbase AI Academy</span>
|
||||
<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-200">Course path</span>
|
||||
<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}</span>
|
||||
<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}</span>
|
||||
{progress?.percent ? <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</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h1 className="text-4xl font-semibold tracking-[-0.06em] text-white md:text-5xl lg:text-[3.75rem]">{course?.title}</h1>
|
||||
{course?.subtitle ? <p className="mt-4 text-sm font-semibold uppercase tracking-[0.24em] text-amber-100/90">{course.subtitle}</p> : null}
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">{course?.excerpt || course?.description}</p>
|
||||
<div className="mt-5 flex items-start justify-between gap-4">
|
||||
<div className="max-w-4xl">
|
||||
{course?.subtitle ? <p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-100/90">{course.subtitle}</p> : null}
|
||||
<h1 className="mt-4 text-4xl font-semibold tracking-[-0.06em] text-white md:text-5xl lg:text-[3.9rem]">{course?.title}</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-200/95 md:text-lg">{course?.excerpt || course?.description}</p>
|
||||
</div>
|
||||
<span className="hidden h-20 w-20 shrink-0 items-center justify-center rounded-[26px] border border-white/10 bg-white/[0.04] text-2xl text-sky-50 shadow-[0_18px_40px_rgba(2,6,23,0.22)] md:flex">
|
||||
<i className="fa-solid fa-route" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<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'}</button>
|
||||
@@ -343,24 +368,41 @@ export default function AcademyCoursesShow({ seo, course, sections = [], unsecti
|
||||
<Link 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</Link>
|
||||
</div>
|
||||
|
||||
<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)]">
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/35 px-4 py-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/75">Library</p>
|
||||
<p className="mt-2 text-lg font-semibold text-white">{totalLessons} lessons</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/35 px-4 py-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/75">Structure</p>
|
||||
<p className="mt-2 text-lg font-semibold text-white">{totalSections} sections</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/35 px-4 py-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/75">Pace</p>
|
||||
<p className="mt-2 text-lg font-semibold text-white">{estimatedMinutes}</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/35 px-4 py-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/75">Status</p>
|
||||
<p className="mt-2 text-lg font-semibold text-white">{progress?.percent ? `${progress.percent}% complete` : 'Ready to start'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="grid gap-4 self-start xl:pt-2">
|
||||
<div className="overflow-hidden rounded-[30px] border border-white/10 bg-black/20 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm">
|
||||
{cover ? (
|
||||
<img src={cover} alt="" aria-hidden="true" className="w-full object-contain" />
|
||||
<img src={cover} alt={course?.title} className="h-56 w-full object-cover" />
|
||||
) : (
|
||||
<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">
|
||||
<div className="flex h-56 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
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="border-t border-white/10 bg-white/[0.03] p-6 xl:border-l xl:border-t-0 xl:p-8">
|
||||
<div className="space-y-4 xl:sticky xl:top-6">
|
||||
<ProgressMeter progress={progress} />
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<div className="rounded-[30px] border border-white/10 bg-black/20 p-5 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Jump through the course</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
{sectionJumpItems.length ? (
|
||||
@@ -380,7 +422,6 @@ export default function AcademyCoursesShow({ seo, course, sections = [], unsecti
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -7,22 +7,110 @@ function academyHref(section, slug) {
|
||||
return `/academy/${section}/${encodeURIComponent(slug)}`
|
||||
}
|
||||
|
||||
function FeatureCard({ title, description, href, cta }) {
|
||||
function formatStatValue(value, singular, plural = `${singular}s`) {
|
||||
const numericValue = Number(value || 0)
|
||||
return `${numericValue.toLocaleString()} ${numericValue === 1 ? singular : plural}`
|
||||
}
|
||||
|
||||
function FeatureCard({ title, description, href, cta, icon, eyebrow, highlights = [], tags = [], meta, theme }) {
|
||||
return (
|
||||
<Link href={href} className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6 transition hover:border-white/20 hover:bg-white/[0.06]">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Academy</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white">{title}</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{description}</p>
|
||||
<span className="mt-5 inline-flex rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100">{cta}</span>
|
||||
<Link href={href} className={`group relative overflow-hidden rounded-[32px] border p-6 shadow-[0_24px_80px_rgba(2,6,23,0.22)] transition hover:-translate-y-1 hover:shadow-[0_30px_95px_rgba(2,6,23,0.32)] ${theme.shell}`}>
|
||||
<div className={`absolute inset-0 ${theme.backdrop}`} />
|
||||
<div className={`absolute inset-0 opacity-60 ${theme.pattern}`} />
|
||||
<div className={`absolute -right-14 top-6 h-32 w-32 rounded-full blur-3xl ${theme.glow}`} />
|
||||
<div className="relative flex min-h-[290px] flex-col">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className={`text-[11px] font-semibold uppercase tracking-[0.24em] ${theme.eyebrow}`}>{eyebrow}</p>
|
||||
<h2 className="mt-4 text-[2rem] font-semibold tracking-[-0.05em] text-white">{title}</h2>
|
||||
</div>
|
||||
<span className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-[18px] border text-lg shadow-[0_14px_34px_rgba(2,6,23,0.28)] transition group-hover:scale-105 ${theme.iconWrap}`}>
|
||||
<i className={icon} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-5 max-w-[34ch] text-sm leading-7 text-slate-200/95">{description}</p>
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
{highlights.map((item) => (
|
||||
<div key={`${title}-${item.label}`} className={`rounded-[22px] border px-4 py-3 backdrop-blur-sm ${theme.highlightCard}`}>
|
||||
<p className={`text-[10px] font-semibold uppercase tracking-[0.18em] ${theme.highlightLabel}`}>{item.label}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-white">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span key={`${title}-${tag}`} className={`rounded-full border px-3 py-1 text-[11px] font-semibold tracking-[0.12em] ${theme.tag}`}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-4 pt-6">
|
||||
<span className={`inline-flex rounded-full border px-4 py-2 text-sm font-semibold transition group-hover:translate-x-1 ${theme.cta}`}>{cta}</span>
|
||||
<span className={`text-right text-[11px] font-semibold uppercase tracking-[0.2em] ${theme.meta}`}>{meta}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureRailCard({ eyebrow, title, description, icon, items = [], emptyText, actionHref = null, actionLabel = null, theme, renderItem }) {
|
||||
return (
|
||||
<section className={`relative overflow-hidden rounded-[30px] border p-6 shadow-[0_22px_70px_rgba(2,6,23,0.26)] ${theme.shell}`}>
|
||||
<div className={`absolute inset-0 ${theme.backdrop}`} />
|
||||
<div className={`absolute inset-0 opacity-60 ${theme.pattern}`} />
|
||||
<div className={`absolute right-0 top-0 h-28 w-28 translate-x-8 -translate-y-6 rounded-full blur-3xl ${theme.glow}`} />
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="max-w-[30ch]">
|
||||
<p className={`text-[11px] font-semibold uppercase tracking-[0.24em] ${theme.eyebrow}`}>{eyebrow}</p>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<span className={`flex h-11 w-11 items-center justify-center rounded-[16px] border text-sm ${theme.iconWrap}`}>
|
||||
<i className={icon} />
|
||||
</span>
|
||||
<h3 className="text-2xl font-semibold tracking-[-0.04em] text-white">{title}</h3>
|
||||
</div>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-200/92">{description}</p>
|
||||
</div>
|
||||
|
||||
{actionHref && actionLabel ? (
|
||||
<Link href={actionHref} className={`inline-flex shrink-0 rounded-full border px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] transition ${theme.action}`}>
|
||||
{actionLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
{items.length > 0 ? items.map((item, index) => renderItem(item, index)) : (
|
||||
<div className={`rounded-[22px] border px-4 py-4 text-sm ${theme.empty}`}>
|
||||
{emptyText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, accent }) {
|
||||
return (
|
||||
<div className={`rounded-[24px] border px-5 py-5 backdrop-blur-sm ${accent.shell}`}>
|
||||
<p className={`text-[10px] font-semibold uppercase tracking-[0.2em] ${accent.label}`}>{label}</p>
|
||||
<p className="mt-3 text-3xl font-semibold tracking-[-0.05em] text-white md:text-[2.4rem]">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeaturedCourseCard({ course }) {
|
||||
const cover = course?.cover_image_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image || ''
|
||||
|
||||
return (
|
||||
<Link 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]">
|
||||
<Link href={course.public_url} className="group relative overflow-hidden rounded-[28px] border border-sky-200/12 bg-[linear-gradient(180deg,rgba(15,23,42,0.9),rgba(15,23,42,0.72))] transition hover:-translate-y-1 hover:border-sky-300/24 hover:shadow-[0_24px_72px_rgba(2,6,23,0.3)]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(125,211,252,0.14),transparent_24%),linear-gradient(135deg,transparent_0%,transparent_48%,rgba(125,211,252,0.05)_48%,rgba(125,211,252,0.05)_52%,transparent_52%,transparent_100%)] opacity-80" />
|
||||
<div className="relative h-44 overflow-hidden bg-[linear-gradient(135deg,rgba(14,165,233,0.24),rgba(15,23,42,0.92))]">
|
||||
{cover ? <img src={cover} alt="" aria-hidden="true" className="h-full w-full object-cover" /> : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.82))]" />
|
||||
@@ -31,17 +119,316 @@ function FeaturedCourseCard({ course }) {
|
||||
<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}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="relative p-5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-sky-100/75">Guided course</p>
|
||||
<h3 className="text-2xl font-semibold tracking-[-0.04em] text-white transition group-hover:text-sky-100">{course.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{course.excerpt || course.description || 'Guided Academy course.'}</p>
|
||||
<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'}</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">{course.lessons_count || 0} lessons · {course.estimated_minutes ? `${course.estimated_minutes} min` : 'Flexible duration'}</p>
|
||||
<span className="inline-flex rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100 transition group-hover:translate-x-1">Open path</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, stats, featuredCourses, featuredLessons, featuredPrompts, featuredChallenges, analytics }) {
|
||||
function formatAccessDate(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = new Date(value)
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(parsed)
|
||||
}
|
||||
|
||||
function academyAccessHeading(access) {
|
||||
switch (access?.status) {
|
||||
case 'staff_access':
|
||||
return 'You currently have full staff access to the Academy.'
|
||||
case 'grace_period':
|
||||
return `${access.tierLabel} access is still active.`
|
||||
case 'trialing':
|
||||
return `${access.tierLabel} trial is active right now.`
|
||||
case 'active':
|
||||
return access?.hasPaidAccess ? `${access.tierLabel} access is active.` : 'Your Academy access is active.'
|
||||
case 'free':
|
||||
return 'You currently have Free access to the Academy.'
|
||||
default:
|
||||
return 'Preview the Academy before you upgrade.'
|
||||
}
|
||||
}
|
||||
|
||||
function academyAccessMeta(access) {
|
||||
const items = [
|
||||
{ label: 'Current tier', value: access?.tierLabel || 'Guest' },
|
||||
{ label: 'Status', value: access?.statusLabel || 'Preview access only' },
|
||||
]
|
||||
|
||||
const formattedDate = formatAccessDate(access?.expiresAt)
|
||||
|
||||
if (formattedDate && access?.dateLabel) {
|
||||
items.push({ label: access.dateLabel, value: formattedDate })
|
||||
} else if (access?.renewsAutomatically) {
|
||||
items.push({ label: 'Billing', value: 'Renews automatically' })
|
||||
} else if (access?.signedIn && !access?.hasPaidAccess) {
|
||||
items.push({ label: 'Upgrade', value: 'Creator and Pro unlock premium workflows' })
|
||||
} else if (!access?.signedIn) {
|
||||
items.push({ label: 'Upgrade', value: 'Sign in to track access and unlock premium content' })
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export default function AcademyIndex({ seo, pricingUrl, academyAccess = null, links, featureFlags, stats, featuredCourses, featuredLessons, featuredPrompts, featuredChallenges, analytics }) {
|
||||
useAcademyPageAnalytics(analytics)
|
||||
const accessHeading = academyAccessHeading(academyAccess)
|
||||
const accessMeta = academyAccessMeta(academyAccess)
|
||||
const useBillingAction = academyAccess?.signedIn && academyAccess?.hasPaidAccess && academyAccess?.billingUrl
|
||||
const accessActionLabel = useBillingAction ? (academyAccess?.status === 'grace_period' ? 'Renew now' : 'Manage billing') : 'See plans'
|
||||
const accessActionHref = useBillingAction ? academyAccess.billingUrl : pricingUrl
|
||||
const academySections = [
|
||||
{
|
||||
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',
|
||||
icon: 'fa-solid fa-route',
|
||||
eyebrow: 'Academy paths',
|
||||
highlights: [
|
||||
{ label: 'Library', value: formatStatValue(stats?.courseCount, 'course') },
|
||||
{ label: 'Includes', value: formatStatValue(stats?.lessonCount, 'lesson') },
|
||||
],
|
||||
tags: ['Progress tracked', 'Learning paths', 'Skill ladders'],
|
||||
meta: 'Structured progression',
|
||||
theme: {
|
||||
shell: 'border-sky-300/18 bg-slate-950/40 hover:border-sky-300/30',
|
||||
backdrop: 'bg-[linear-gradient(145deg,rgba(14,165,233,0.16),rgba(15,23,42,0.95)_42%,rgba(16,185,129,0.18))]',
|
||||
pattern: 'bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.16),transparent_30%),linear-gradient(125deg,transparent_0%,transparent_45%,rgba(125,211,252,0.08)_45%,rgba(125,211,252,0.08)_52%,transparent_52%,transparent_100%)]',
|
||||
glow: 'bg-sky-300/25',
|
||||
eyebrow: 'text-sky-100/80',
|
||||
iconWrap: 'border-sky-200/20 bg-sky-300/12 text-sky-100',
|
||||
highlightCard: 'border-sky-200/12 bg-slate-950/40',
|
||||
highlightLabel: 'text-sky-100/75',
|
||||
tag: 'border-sky-200/12 bg-sky-300/10 text-sky-100',
|
||||
cta: 'border-sky-300/25 bg-sky-300/12 text-sky-100',
|
||||
meta: 'text-sky-100/75',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Lessons',
|
||||
description: 'Structured tutorials for prompt writing, cleanup workflows, AI ethics, and Skinbase-native publishing habits.',
|
||||
href: links.lessons,
|
||||
cta: 'Open lessons',
|
||||
icon: 'fa-solid fa-book-open-reader',
|
||||
eyebrow: 'Focused tutorials',
|
||||
highlights: [
|
||||
{ label: 'Depth', value: formatStatValue(stats?.lessonCount, 'lesson') },
|
||||
{ label: 'Coverage', value: 'Prompt craft + workflow cleanup' },
|
||||
],
|
||||
tags: ['Short wins', 'Creative habits', 'Practical steps'],
|
||||
meta: 'Skill-by-skill learning',
|
||||
theme: {
|
||||
shell: 'border-amber-300/18 bg-slate-950/40 hover:border-amber-300/30',
|
||||
backdrop: 'bg-[linear-gradient(160deg,rgba(251,191,36,0.18),rgba(15,23,42,0.95)_40%,rgba(249,115,22,0.14))]',
|
||||
pattern: 'bg-[radial-gradient(circle_at_top_right,rgba(253,230,138,0.14),transparent_28%),linear-gradient(180deg,transparent_0%,transparent_54%,rgba(251,191,36,0.08)_54%,rgba(251,191,36,0.08)_58%,transparent_58%,transparent_100%)]',
|
||||
glow: 'bg-amber-300/20',
|
||||
eyebrow: 'text-amber-100/85',
|
||||
iconWrap: 'border-amber-200/20 bg-amber-300/12 text-amber-100',
|
||||
highlightCard: 'border-amber-200/12 bg-slate-950/42',
|
||||
highlightLabel: 'text-amber-100/75',
|
||||
tag: 'border-amber-200/12 bg-amber-300/10 text-amber-100',
|
||||
cta: 'border-amber-300/25 bg-amber-300/12 text-amber-100',
|
||||
meta: 'text-amber-100/75',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Prompt Library',
|
||||
description: 'Discover reusable prompt templates, locked premium previews, and creator-focused visual workflows.',
|
||||
href: links.prompts,
|
||||
cta: 'Explore prompts',
|
||||
icon: 'fa-solid fa-wand-magic-sparkles',
|
||||
eyebrow: 'Reusable prompt kits',
|
||||
highlights: [
|
||||
{ label: 'Templates', value: formatStatValue(stats?.promptCount, 'prompt') },
|
||||
{ label: 'Use case', value: 'Reusable systems + premium previews' },
|
||||
],
|
||||
tags: ['Fast starts', 'Visual workflows', 'Copy + adapt'],
|
||||
meta: 'High-speed ideation',
|
||||
theme: {
|
||||
shell: 'border-rose-300/18 bg-slate-950/40 hover:border-rose-300/30',
|
||||
backdrop: 'bg-[linear-gradient(150deg,rgba(244,63,94,0.16),rgba(15,23,42,0.95)_38%,rgba(45,212,191,0.16))]',
|
||||
pattern: 'bg-[radial-gradient(circle_at_20%_15%,rgba(251,113,133,0.16),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,22px_22px,22px_22px]',
|
||||
glow: 'bg-rose-300/20',
|
||||
eyebrow: 'text-rose-100/85',
|
||||
iconWrap: 'border-rose-200/20 bg-rose-300/12 text-rose-100',
|
||||
highlightCard: 'border-rose-200/12 bg-slate-950/42',
|
||||
highlightLabel: 'text-rose-100/75',
|
||||
tag: 'border-rose-200/12 bg-rose-300/10 text-rose-100',
|
||||
cta: 'border-rose-300/25 bg-rose-300/12 text-rose-100',
|
||||
meta: 'text-rose-100/75',
|
||||
},
|
||||
},
|
||||
]
|
||||
const academyFeatureRails = [
|
||||
{
|
||||
key: 'lessons',
|
||||
eyebrow: 'Featured lessons',
|
||||
title: 'Jump-in tutorials',
|
||||
description: 'Shorter Academy pieces for specific prompt problems, cleanup workflows, and publishing habits.',
|
||||
icon: 'fa-solid fa-book-open-reader',
|
||||
actionHref: links.lessons,
|
||||
actionLabel: 'All lessons',
|
||||
items: (featuredLessons || []).slice(0, 3),
|
||||
emptyText: 'Featured lessons will appear here when the Academy team highlights a new tutorial.',
|
||||
theme: {
|
||||
shell: 'border-amber-300/16 bg-slate-950/45',
|
||||
backdrop: 'bg-[linear-gradient(160deg,rgba(251,191,36,0.15),rgba(15,23,42,0.96)_42%,rgba(249,115,22,0.14))]',
|
||||
pattern: 'bg-[radial-gradient(circle_at_top_right,rgba(253,230,138,0.12),transparent_24%),linear-gradient(180deg,transparent_0%,transparent_52%,rgba(251,191,36,0.08)_52%,rgba(251,191,36,0.08)_56%,transparent_56%,transparent_100%)]',
|
||||
glow: 'bg-amber-300/18',
|
||||
eyebrow: 'text-amber-100/82',
|
||||
iconWrap: 'border-amber-200/20 bg-amber-300/12 text-amber-100',
|
||||
action: 'border-amber-300/22 bg-amber-300/10 text-amber-100 hover:border-amber-300/34 hover:bg-amber-300/16',
|
||||
item: 'border-amber-200/10 bg-slate-950/38 hover:border-amber-200/18 hover:bg-slate-950/52',
|
||||
itemEyebrow: 'text-amber-100/75',
|
||||
itemMeta: 'text-amber-100/70',
|
||||
empty: 'border-amber-200/10 bg-slate-950/30 text-amber-50/80',
|
||||
},
|
||||
renderItem: (item, index, theme) => (
|
||||
<Link key={item.id} href={academyHref('lessons', item.slug)} className={`group block rounded-[22px] border px-4 py-4 transition ${theme.item}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold ${theme.iconWrap}`}>{index + 1}</span>
|
||||
<div className="min-w-0">
|
||||
<span className={`block text-[10px] font-semibold uppercase tracking-[0.18em] ${theme.itemEyebrow}`}>{item.lesson_label || 'Featured lesson'}</span>
|
||||
<span className="mt-2 block text-sm font-semibold text-white transition group-hover:text-amber-50">{item.title}</span>
|
||||
<span className={`mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] ${theme.itemMeta}`}>Practical tutorial</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'prompts',
|
||||
eyebrow: 'Featured prompts',
|
||||
title: 'Reusable prompt packs',
|
||||
description: 'Template-driven prompt entries designed for fast reuse, remixing, and premium workflow previews.',
|
||||
icon: 'fa-solid fa-wand-magic-sparkles',
|
||||
actionHref: links.promptPopular,
|
||||
actionLabel: 'Top prompts',
|
||||
items: (featuredPrompts || []).slice(0, 3),
|
||||
emptyText: 'Featured prompts will appear here when reusable prompt templates are promoted on the homepage.',
|
||||
theme: {
|
||||
shell: 'border-rose-300/16 bg-slate-950/45',
|
||||
backdrop: 'bg-[linear-gradient(155deg,rgba(244,63,94,0.14),rgba(15,23,42,0.96)_40%,rgba(45,212,191,0.14))]',
|
||||
pattern: 'bg-[radial-gradient(circle_at_20%_18%,rgba(251,113,133,0.14),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,22px_22px,22px_22px]',
|
||||
glow: 'bg-rose-300/18',
|
||||
eyebrow: 'text-rose-100/82',
|
||||
iconWrap: 'border-rose-200/20 bg-rose-300/12 text-rose-100',
|
||||
action: 'border-rose-300/22 bg-rose-300/10 text-rose-100 hover:border-rose-300/34 hover:bg-rose-300/16',
|
||||
item: 'border-rose-200/10 bg-slate-950/38 hover:border-rose-200/18 hover:bg-slate-950/52',
|
||||
itemEyebrow: 'text-rose-100/75',
|
||||
itemMeta: 'text-rose-100/70',
|
||||
empty: 'border-rose-200/10 bg-slate-950/30 text-rose-50/80',
|
||||
},
|
||||
renderItem: (item, index, theme) => (
|
||||
<Link key={item.id} href={academyHref('prompts', item.slug)} className={`group block rounded-[22px] border px-4 py-4 transition ${theme.item}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<span className={`block text-[10px] font-semibold uppercase tracking-[0.18em] ${theme.itemEyebrow}`}>Prompt template #{index + 1}</span>
|
||||
<span className="mt-2 block text-sm font-semibold text-white transition group-hover:text-rose-50">{item.title}</span>
|
||||
</div>
|
||||
<span className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${theme.iconWrap}`}>Template</span>
|
||||
</div>
|
||||
<span className={`mt-3 block text-[11px] font-semibold uppercase tracking-[0.16em] ${theme.itemMeta}`}>Reusable workflow</span>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'challenges',
|
||||
eyebrow: 'Current challenges',
|
||||
title: 'Build around a brief',
|
||||
description: 'Academy challenges turn lessons and prompt systems into practical output with a clear creative objective.',
|
||||
icon: 'fa-solid fa-trophy',
|
||||
items: (featuredChallenges || []).slice(0, 3),
|
||||
emptyText: 'Current challenges will appear here when the Academy team launches a new guided brief.',
|
||||
theme: {
|
||||
shell: 'border-emerald-300/16 bg-slate-950/45',
|
||||
backdrop: 'bg-[linear-gradient(155deg,rgba(16,185,129,0.14),rgba(15,23,42,0.96)_42%,rgba(56,189,248,0.12))]',
|
||||
pattern: 'bg-[radial-gradient(circle_at_top_left,rgba(110,231,183,0.14),transparent_24%),linear-gradient(135deg,transparent_0%,transparent_48%,rgba(16,185,129,0.08)_48%,rgba(16,185,129,0.08)_56%,transparent_56%,transparent_100%)]',
|
||||
glow: 'bg-emerald-300/18',
|
||||
eyebrow: 'text-emerald-100/82',
|
||||
iconWrap: 'border-emerald-200/20 bg-emerald-300/12 text-emerald-100',
|
||||
action: 'border-emerald-300/22 bg-emerald-300/10 text-emerald-100 hover:border-emerald-300/34 hover:bg-emerald-300/16',
|
||||
item: 'border-emerald-200/10 bg-slate-950/38 hover:border-emerald-200/18 hover:bg-slate-950/52',
|
||||
itemEyebrow: 'text-emerald-100/75',
|
||||
itemMeta: 'text-emerald-100/70',
|
||||
empty: 'border-emerald-200/10 bg-slate-950/30 text-emerald-50/80',
|
||||
},
|
||||
renderItem: (item, index, theme) => (
|
||||
<Link key={item.id} href={academyHref('challenges', item.slug)} className={`group block rounded-[22px] border px-4 py-4 transition ${theme.item}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`mt-0.5 flex h-8 min-w-8 items-center justify-center rounded-full border px-2 text-[10px] font-semibold ${theme.iconWrap}`}>#{index + 1}</span>
|
||||
<div className="min-w-0">
|
||||
<span className={`block text-[10px] font-semibold uppercase tracking-[0.18em] ${theme.itemEyebrow}`}>Active brief</span>
|
||||
<span className="mt-2 block text-sm font-semibold text-white transition group-hover:text-emerald-50">{item.title}</span>
|
||||
<span className={`mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] ${theme.itemMeta}`}>Apply what you learned</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
]
|
||||
const handleAccessAction = () => {
|
||||
if (!useBillingAction) {
|
||||
trackUpgradeClick(analytics, { source: 'academy_home_hero' })
|
||||
}
|
||||
}
|
||||
const academyMetrics = [
|
||||
{
|
||||
key: 'courses',
|
||||
label: 'Courses',
|
||||
value: stats?.courseCount || 0,
|
||||
accent: {
|
||||
shell: 'border-sky-300/14 bg-sky-300/[0.08]',
|
||||
label: 'text-sky-100/78',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'lessons',
|
||||
label: 'Lessons',
|
||||
value: stats?.lessonCount || 0,
|
||||
accent: {
|
||||
shell: 'border-amber-300/14 bg-amber-300/[0.08]',
|
||||
label: 'text-amber-100/78',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'prompts',
|
||||
label: 'Prompts',
|
||||
value: stats?.promptCount || 0,
|
||||
accent: {
|
||||
shell: 'border-rose-300/14 bg-rose-300/[0.08]',
|
||||
label: 'text-rose-100/78',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'challenges',
|
||||
label: 'Challenges',
|
||||
value: stats?.challengeCount || 0,
|
||||
accent: {
|
||||
shell: 'border-emerald-300/14 bg-emerald-300/[0.08]',
|
||||
label: 'text-emerald-100/78',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const jsonLd = [{
|
||||
'@context': 'https://schema.org',
|
||||
@@ -52,68 +439,115 @@ export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, sta
|
||||
}]
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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-6 sm:px-6 sm:py-8 lg:px-8 lg:py-10">
|
||||
<SeoHead seo={seo || {}} title="Skinbase AI Academy" description={seo?.description} jsonLd={jsonLd} />
|
||||
|
||||
<div className="mx-auto max-w-[1440px] space-y-8">
|
||||
<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">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-end">
|
||||
<div className="mx-auto max-w-[1440px] space-y-6 md:space-y-8 xl:space-y-10">
|
||||
<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-6 shadow-[0_32px_100px_rgba(2,6,23,0.38)] md:p-7 lg:p-8">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_300px] xl:items-center">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-amber-200/80">Skinbase AI Academy</p>
|
||||
<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.</h1>
|
||||
<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.</p>
|
||||
<h1 className="mt-3 max-w-[15ch] text-4xl font-semibold tracking-[-0.05em] text-white md:max-w-[16ch] md:text-5xl xl:max-w-[19ch] xl:text-[3.2rem]">Learn how to turn prompts into wallpapers, digital art, skins, covers, and visual worlds.</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-7 text-slate-300 md:text-lg md:leading-8">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.</p>
|
||||
|
||||
<div className="mt-7 flex flex-wrap gap-3">
|
||||
<div className="mt-5 flex flex-wrap gap-2.5">
|
||||
<Link 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</Link>
|
||||
<Link 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</Link>
|
||||
<Link 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</Link>
|
||||
<Link href={links.promptPopular} className="rounded-full border border-rose-300/25 bg-rose-300/12 px-5 py-3 text-sm font-semibold text-rose-100 transition hover:border-rose-300/40 hover:bg-rose-300/18">Top prompts</Link>
|
||||
<Link 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</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[30px] border border-white/10 bg-black/20 p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">Launch status</p>
|
||||
<div className="mt-4 space-y-3 text-sm text-slate-300">
|
||||
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3"><span>Challenges</span><span>{featureFlags?.challengesEnabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3"><span>Badges</span><span>{featureFlags?.badgesEnabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3"><span>Payments</span><span>{featureFlags?.paymentsEnabled ? 'Preview only' : 'Disabled'}</span></div>
|
||||
<div className="rounded-[30px] border border-sky-300/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.16),rgba(15,23,42,0.82))] p-4 md:p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-2xl border border-sky-300/25 bg-sky-300/12 text-sky-100"><i className="fa-solid fa-crown text-sm" /></span>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100/80">Your Academy access</p>
|
||||
<p className="mt-1 text-lg font-semibold text-white">{accessHeading}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3">
|
||||
{accessMeta.map((item) => (
|
||||
<div key={item.label} className="flex items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200">
|
||||
<span className="text-slate-300">{item.label}</span>
|
||||
<span className="font-semibold text-white">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Link href={accessActionHref} onClick={handleAccessAction} className="mt-4 inline-flex 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">{accessActionLabel}</Link>
|
||||
{academyAccess?.status === 'grace_period' ? <p className="mt-2 text-xs text-sky-100/75">Opens billing account to restore renewal before access ends.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-5 lg:grid-cols-3">
|
||||
<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" />
|
||||
<FeatureCard title="Lessons" description="Structured tutorials for prompt writing, cleanup workflows, AI ethics, and Skinbase-native publishing habits." href={links.lessons} cta="Open lessons" />
|
||||
<FeatureCard title="Prompt Library" description="Discover reusable prompt templates, locked premium previews, and creator-focused visual workflows." href={links.prompts} cta="Explore prompts" />
|
||||
<section className="space-y-4 md:space-y-5">
|
||||
<div className="flex flex-col gap-3 rounded-[30px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.6),rgba(15,23,42,0.22))] px-5 py-4 shadow-[0_16px_48px_rgba(2,6,23,0.18)] md:flex-row md:items-end md:justify-between md:px-6">
|
||||
<div className="max-w-2xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-100/75">Choose your Academy lane</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.045em] text-white md:text-[2rem]">Start with the format that matches how you learn.</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">
|
||||
<span className="h-px flex-1 bg-gradient-to-r from-transparent via-sky-300/35 to-transparent md:min-w-24" />
|
||||
<span>Courses, lessons, prompts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-3">
|
||||
{academySections.map((section) => (
|
||||
<FeatureCard key={section.title} {...section} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-5 lg:grid-cols-4">
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Courses</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.courseCount || 0}</p></div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Lessons</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.lessonCount || 0}</p></div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Prompts</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.promptCount || 0}</p></div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Challenges</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.challengeCount || 0}</p></div>
|
||||
<section className="overflow-hidden rounded-[34px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.82),rgba(15,23,42,0.58)),radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_30%),radial-gradient(circle_at_bottom_right,rgba(251,191,36,0.12),transparent_28%)] p-3 shadow-[0_18px_56px_rgba(2,6,23,0.24)] sm:p-4">
|
||||
<div className="grid gap-3 lg:grid-cols-4">
|
||||
{academyMetrics.map((metric) => (
|
||||
<MetricCard key={metric.key} label={metric.label} value={metric.value} accent={metric.accent} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{featuredCourses?.length ? (
|
||||
<section className="space-y-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Featured courses</p>
|
||||
<section className="relative overflow-hidden rounded-[36px] border border-sky-200/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.82),rgba(3,7,18,0.94)),radial-gradient(circle_at_top_left,rgba(14,165,233,0.14),transparent_28%),radial-gradient(circle_at_bottom_right,rgba(16,185,129,0.1),transparent_30%)] p-5 shadow-[0_24px_70px_rgba(2,6,23,0.26)] md:p-6 lg:p-7">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(90deg,rgba(255,255,255,0.03)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[length:28px_28px] opacity-25" />
|
||||
<div className="relative space-y-4 md:space-y-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4 rounded-[28px] border border-white/10 bg-black/15 px-5 py-4 backdrop-blur-sm">
|
||||
<div className="max-w-2xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-100/78">Featured courses</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold tracking-[-0.045em] text-white">Guided Academy paths</h2>
|
||||
<p className="mt-3 max-w-[54ch] text-sm leading-7 text-slate-300">Longer learning paths for people who want a clearer start-to-finish route instead of individual tutorials or standalone prompt templates.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full border border-sky-300/18 bg-sky-300/10 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-100/85">
|
||||
{formatStatValue(featuredCourses.length, 'featured path')}
|
||||
</div>
|
||||
<Link href={links.courses} 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.08]">All courses</Link>
|
||||
</div>
|
||||
<Link 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</Link>
|
||||
</div>
|
||||
<div className="grid gap-5 xl:grid-cols-3">
|
||||
{featuredCourses.slice(0, 3).map((course) => <FeaturedCourseCard key={course.id} course={course} />)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-5 xl:grid-cols-3">
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Featured lessons</p><div className="mt-4 space-y-3">{(featuredLessons || []).slice(0, 3).map((item) => <Link key={item.id} href={academyHref('lessons', item.slug)} className="block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><span className="block text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{item.lesson_label || 'Featured lesson'}</span><span className="mt-1 block">{item.title}</span></Link>)}</div></div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Featured prompts</p><div className="mt-4 space-y-3">{(featuredPrompts || []).slice(0, 3).map((item) => <Link key={item.id} href={academyHref('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}</Link>)}</div></div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Current challenges</p><div className="mt-4 space-y-3">{(featuredChallenges || []).slice(0, 3).map((item) => <Link key={item.id} href={academyHref('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}</Link>)}</div></div>
|
||||
<section className="grid gap-4 xl:grid-cols-3 xl:gap-5">
|
||||
{academyFeatureRails.map((rail) => (
|
||||
<FeatureRailCard
|
||||
key={rail.key}
|
||||
eyebrow={rail.eyebrow}
|
||||
title={rail.title}
|
||||
description={rail.description}
|
||||
icon={rail.icon}
|
||||
items={rail.items}
|
||||
emptyText={rail.emptyText}
|
||||
actionHref={rail.actionHref}
|
||||
actionLabel={rail.actionLabel}
|
||||
theme={rail.theme}
|
||||
renderItem={(item, index) => rail.renderItem(item, index, rail.theme)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -8,6 +8,31 @@ function academyHref(section, slug) {
|
||||
return `/academy/${section}/${encodeURIComponent(slug)}`
|
||||
}
|
||||
|
||||
function Breadcrumbs({ items = [] }) {
|
||||
if (!items.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${item.label}-${index}`}>
|
||||
{isLast ? (
|
||||
<span className="text-white/80">{item.label}</span>
|
||||
) : (
|
||||
<Link href={item.href} className="transition hover:text-white">{item.label}</Link>
|
||||
)}
|
||||
{!isLast ? <span className="text-slate-600">/</span> : null}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function QueryFilters({ pageType, filters, categories }) {
|
||||
if (pageType !== 'lessons' && pageType !== 'prompts') {
|
||||
return null
|
||||
@@ -88,79 +113,455 @@ function promptPreviewAsset(item) {
|
||||
}
|
||||
}
|
||||
|
||||
function PromptLibraryHero({ title, description, items, pricingUrl, totalCount }) {
|
||||
const featuredImages = (items || [])
|
||||
.map((item) => promptPreviewAsset(item))
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
function lessonPreviewAsset(item) {
|
||||
const src = item?.cover_image_url || item?.article_cover_image_url || item?.cover_image || item?.article_cover_image || ''
|
||||
|
||||
const primaryImage = featuredImages[0] || null
|
||||
const supportingImages = featuredImages.slice(1, 3)
|
||||
if (!src) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { src }
|
||||
}
|
||||
|
||||
function PromptSpotlightCard({ item }) {
|
||||
const preview = promptPreviewAsset(item)
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1.15fr)_420px] xl:items-end">
|
||||
<Link href={academyHref('prompts', item.slug)} className="group rounded-[28px] border border-white/10 bg-white/[0.04] p-4 transition hover:border-sky-300/25 hover:bg-white/[0.06]">
|
||||
<div className="grid gap-4 sm:grid-cols-[104px_minmax(0,1fr)] sm:items-center">
|
||||
<div className="overflow-hidden rounded-[22px] border border-white/10 bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(17,24,39,0.94))] aspect-square">
|
||||
{preview ? <img src={preview.src} srcSet={preview.srcSet || undefined} sizes="104px" alt="" aria-hidden="true" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100">{item?.spotlight?.eyebrow || 'Prompt pick'}</span>
|
||||
{item?.difficulty ? <span className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">{item.difficulty}</span> : null}
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold tracking-[-0.03em] text-white transition group-hover:text-sky-100">{item.title}</h3>
|
||||
<p className="mt-2 line-clamp-2 text-sm leading-6 text-slate-300">{item.excerpt || item.prompt_preview || item.description || 'Reusable prompt template.'}</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
<span>{item?.category?.name || 'Academy'}</span>
|
||||
{item?.tags?.[0] ? <span>{item.tags[0]}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptDiscoverySection({ id, title, description, items = [], href, ctaLabel }) {
|
||||
if (!items.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section id={id} className="rounded-[34px] border border-white/10 bg-black/20 p-6 md:p-7">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<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</span>
|
||||
<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</span>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-200/80">Prompt discovery</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white md:text-3xl">{title}</h2>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-7 text-slate-300">{description}</p>
|
||||
</div>
|
||||
{href && ctaLabel ? <Link href={href} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.09]">{ctaLabel}</Link> : null}
|
||||
</div>
|
||||
<h1 className="mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl xl:text-6xl">{title}</h1>
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">{description}</p>
|
||||
|
||||
<div className="mt-7 grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Visual-first</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">Preview prompt results before opening the detail page.</p>
|
||||
<div className="mt-6 grid gap-4 xl:grid-cols-2">
|
||||
{items.map((item) => <PromptSpotlightCard key={`spotlight-${item.id}`} item={item} />)}
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Reusable</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">Templates for wallpapers, covers, worlds, portraits, and more.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function PopularPromptPeriodTabs({ currentPeriod, periods = [] }) {
|
||||
if (!periods.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{periods.map((period) => (
|
||||
<Link
|
||||
key={period.value}
|
||||
href={period.href}
|
||||
className={`rounded-2xl border px-4 py-3 text-left transition ${period.active ? 'border-sky-200/35 bg-sky-200/15 text-sky-50' : 'border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]'}`}
|
||||
>
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em]">{period.label}</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-inherit/80">{period.description}</span>
|
||||
</Link>
|
||||
))}
|
||||
{currentPeriod?.description ? <div className="flex items-center rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs text-slate-300">{currentPeriod.description}</div> : null}
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Comparison-ready</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">See which prompts include provider-specific notes and outputs.</p>
|
||||
)
|
||||
}
|
||||
|
||||
function formatAccessDate(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = new Date(value)
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(parsed)
|
||||
}
|
||||
|
||||
function academyAccessHeading(access) {
|
||||
switch (access?.status) {
|
||||
case 'staff_access':
|
||||
return 'You currently have full staff access to the Academy.'
|
||||
case 'grace_period':
|
||||
return `${access.tierLabel} access is still active.`
|
||||
case 'trialing':
|
||||
return `${access.tierLabel} trial is active right now.`
|
||||
case 'active':
|
||||
return access?.hasPaidAccess ? `${access.tierLabel} access is active.` : 'Your Academy access is active.'
|
||||
case 'free':
|
||||
return 'You currently have Free access to the Academy.'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function academyAccessMeta(access) {
|
||||
if (!access?.signedIn) {
|
||||
return []
|
||||
}
|
||||
|
||||
const items = [
|
||||
{ label: 'Current tier', value: access?.tierLabel || 'Free' },
|
||||
{ label: 'Status', value: access?.statusLabel || 'Free access' },
|
||||
]
|
||||
|
||||
const formattedDate = formatAccessDate(access?.expiresAt)
|
||||
|
||||
if (formattedDate && access?.dateLabel) {
|
||||
items.push({ label: access.dateLabel, value: formattedDate })
|
||||
} else if (access?.renewsAutomatically) {
|
||||
items.push({ label: 'Billing', value: 'Renews automatically' })
|
||||
} else if (!access?.hasPaidAccess) {
|
||||
items.push({ label: 'Upgrade', value: 'Creator and Pro unlock premium prompts' })
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function PromptLibraryHero({ promptView = 'library', title, description, items, pricingUrl, coursesUrl, packsUrl, promptPopularUrl, promptLibraryUrl, popularPeriod, popularPeriods = [], totalCount, analytics, hasPopularSection, academyAccess = null }) {
|
||||
const isPopularView = promptView === 'popular'
|
||||
const statLabel = isPopularView ? `ranked prompts ${currentPeriodStatSuffix(popularPeriod)}` : 'prompts available'
|
||||
const showSignedInAccess = Boolean(academyAccess?.signedIn)
|
||||
const accessHeading = academyAccessHeading(academyAccess)
|
||||
const accessMeta = academyAccessMeta(academyAccess)
|
||||
const useBillingAction = showSignedInAccess && academyAccess?.hasPaidAccess && academyAccess?.billingUrl
|
||||
const primaryActionLabel = useBillingAction ? (academyAccess?.status === 'grace_period' ? 'Renew now' : 'Manage billing') : 'Upgrade now'
|
||||
const primaryActionIcon = useBillingAction ? (academyAccess?.status === 'grace_period' ? 'fa-solid fa-rotate-right' : 'fa-solid fa-sliders') : 'fa-solid fa-arrow-up-right-from-square'
|
||||
const primaryActionHref = useBillingAction ? academyAccess.billingUrl : pricingUrl
|
||||
const secondaryAction = isPopularView
|
||||
? { href: promptLibraryUrl, label: 'Browse full library', icon: 'fa-solid fa-grid-2' }
|
||||
: (hasPopularSection
|
||||
? { href: promptPopularUrl, label: 'Top prompts', icon: 'fa-solid fa-fire' }
|
||||
: { href: coursesUrl, label: 'Explore courses', icon: 'fa-solid fa-graduation-cap' })
|
||||
const heroHighlights = [
|
||||
{
|
||||
label: isPopularView ? 'Ranking window' : 'Templates',
|
||||
value: isPopularView ? `${totalCount || 0} ranked prompts` : `${totalCount || 0} prompts`,
|
||||
},
|
||||
{
|
||||
label: 'Use case',
|
||||
value: isPopularView ? 'High-performing systems + trend tracking' : 'Reusable systems + premium previews',
|
||||
},
|
||||
]
|
||||
const heroTags = isPopularView
|
||||
? ['Momentum picks', 'Copy trends', 'Compare windows']
|
||||
: ['Fast starts', 'Visual workflows', 'Copy + adapt']
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (!useBillingAction) {
|
||||
trackUpgradeClick(analytics, { source: 'prompts_library_hero_primary' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden rounded-[40px] border border-rose-200/12 bg-[linear-gradient(150deg,rgba(244,63,94,0.14),rgba(15,23,42,0.96)_36%,rgba(45,212,191,0.14))] p-5 shadow-[0_32px_96px_rgba(2,6,23,0.32)] md:p-6 xl:p-7">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_14%,rgba(251,113,133,0.15),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,24px_24px,24px_24px] opacity-75" />
|
||||
<div className="absolute -left-8 top-12 h-36 w-36 rounded-full bg-rose-300/18 blur-3xl" />
|
||||
<div className="absolute -right-10 bottom-0 h-40 w-40 rounded-full bg-cyan-300/14 blur-3xl" />
|
||||
|
||||
<div className="relative grid gap-5 xl:grid-cols-[minmax(0,1.1fr)_360px] xl:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-rose-200/18 bg-rose-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-rose-50/90">Skinbase AI Academy</span>
|
||||
<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-200">{isPopularView ? 'Popular prompts' : 'Prompt Library'}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">{totalCount || 0} {statLabel}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-start justify-between gap-4">
|
||||
<div className="max-w-4xl">
|
||||
<h1 className="max-w-[13ch] text-4xl font-semibold tracking-[-0.05em] text-white md:max-w-[14ch] md:text-[4.2rem] xl:max-w-[15ch] xl:text-[4.5rem]">{title}</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-200/95">{description}</p>
|
||||
</div>
|
||||
<span className="hidden h-20 w-20 shrink-0 items-center justify-center rounded-[26px] border border-white/10 bg-white/[0.04] text-2xl text-rose-50 shadow-[0_18px_40px_rgba(2,6,23,0.22)] md:flex">
|
||||
<i className="fa-solid fa-wand-magic-sparkles" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isPopularView ? <div className="mt-5"><PopularPromptPeriodTabs currentPeriod={popularPeriod} periods={popularPeriods} /></div> : null}
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
{heroHighlights.map((item) => (
|
||||
<div key={item.label} className="rounded-[24px] border border-white/10 bg-slate-950/35 px-5 py-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-rose-100/75">{item.label}</p>
|
||||
<p className="mt-2 text-lg font-semibold leading-8 text-white">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2.5">
|
||||
{heroTags.map((tag) => (
|
||||
<span key={tag} className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-rose-50/90">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link href={primaryActionHref} onClick={handlePrimaryAction} className="inline-flex items-center gap-2 rounded-full border border-rose-200/26 bg-rose-300/12 px-5 py-3 text-sm font-semibold text-rose-50 transition hover:border-rose-200/36 hover:bg-rose-300/18">
|
||||
<i className={`${primaryActionIcon} text-xs`} />
|
||||
{primaryActionLabel}
|
||||
</Link>
|
||||
<Link href={secondaryAction.href} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white/90 transition hover:bg-white/[0.1]">
|
||||
<i className={`${secondaryAction.icon} text-xs`} />
|
||||
{secondaryAction.label}
|
||||
</Link>
|
||||
<Link href={packsUrl} className="inline-flex items-center gap-2 rounded-full border border-cyan-200/20 bg-cyan-300/10 px-5 py-3 text-sm font-semibold text-cyan-50 transition hover:border-cyan-200/30 hover:bg-cyan-300/16">
|
||||
<i className="fa-solid fa-box-open text-xs" />
|
||||
See prompt packs
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-7 flex flex-wrap gap-3">
|
||||
<Link 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</Link>
|
||||
<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</span>
|
||||
<div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-4 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm md:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Quick routes</p>
|
||||
<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-white/80">{totalCount || 0} total</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<Link href={isPopularView ? promptLibraryUrl : coursesUrl} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-[0.9rem] text-sm font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
<span>{isPopularView ? 'Browse full prompt library' : 'Browse Academy courses'}</span>
|
||||
<i className="fa-solid fa-arrow-right text-xs text-slate-400" />
|
||||
</Link>
|
||||
<Link href={packsUrl} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-[0.9rem] text-sm font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
<span>See prompt packs</span>
|
||||
<i className="fa-solid fa-box-open text-xs text-slate-400" />
|
||||
</Link>
|
||||
{isPopularView ? (
|
||||
<Link href={coursesUrl} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-[0.9rem] text-sm font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
<span>Explore Academy courses</span>
|
||||
<i className="fa-solid fa-graduation-cap text-xs text-slate-400" />
|
||||
</Link>
|
||||
) : hasPopularSection ? (
|
||||
<Link href={promptPopularUrl} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-[0.9rem] text-sm font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
<span>Open top prompts page</span>
|
||||
<i className="fa-solid fa-fire text-xs text-slate-400" />
|
||||
</Link>
|
||||
) : null}
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-[0.9rem] text-sm font-semibold text-white/85">{isPopularView ? 'Use the period tabs to compare momentum windows.' : 'Jump straight into packs, courses, or ranked prompts.'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{primaryImage ? (
|
||||
<>
|
||||
<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]">
|
||||
<img src={primaryImage.src} srcSet={primaryImage.srcSet || undefined} sizes="(max-width: 1279px) calc(100vw - 4rem), 420px" alt="" aria-hidden="true" className="h-full w-full object-cover" />
|
||||
<div className="xl:col-span-2">
|
||||
<div className="rounded-[28px] border border-sky-300/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.16),rgba(15,23,42,0.82))] p-4 shadow-[0_18px_45px_rgba(2,6,23,0.2)] md:p-5 lg:p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-sky-300/25 bg-sky-300/12 text-sky-100"><i className="fa-solid fa-crown text-sm" /></span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-100/80">{showSignedInAccess ? 'Your Academy access' : (isPopularView ? 'Turn rankings into results' : 'Upgrade for full access')}</p>
|
||||
<p className="mt-1 max-w-4xl text-lg font-semibold text-white md:text-[1.85rem] md:leading-[1.45]">{showSignedInAccess ? accessHeading : (isPopularView ? 'Open the highest-performing prompts, then unlock the full text, helper prompts, variants, and premium workflows.' : 'Unlock full prompt text, helper prompts, variants, and premium workflows.')}</p>
|
||||
</div>
|
||||
|
||||
{supportingImages.length ? (
|
||||
<div className={`grid gap-3 ${supportingImages.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}>
|
||||
{supportingImages.map((image, index) => (
|
||||
<div key={`${image.src}-${index}`} className="overflow-hidden rounded-[28px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.18)] aspect-square">
|
||||
<img src={image.src} srcSet={image.srcSet || undefined} sizes="(max-width: 1279px) calc(50vw - 2rem), 200px" alt="" aria-hidden="true" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
{showSignedInAccess ? (
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{accessMeta.map((item) => (
|
||||
<div key={item.label} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 md:px-5 md:py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/70">{item.label}</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white md:text-base">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<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
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Link href={primaryActionHref} onClick={handlePrimaryAction} className="inline-flex items-center gap-2 rounded-full border border-sky-200/30 bg-sky-200/15 px-5 py-2.5 text-sm font-semibold text-sky-50 transition hover:bg-sky-200/22">
|
||||
<i className={`${primaryActionIcon} text-xs`} />
|
||||
{primaryActionLabel}
|
||||
</Link>
|
||||
<Link href={secondaryAction.href} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-5 py-2.5 text-sm font-semibold text-white/90 transition hover:bg-white/[0.1]">
|
||||
<i className={`${secondaryAction.icon} text-xs`} />
|
||||
{secondaryAction.label}
|
||||
</Link>
|
||||
</div>
|
||||
{academyAccess?.status === 'grace_period' ? <p className="mt-2 text-xs text-sky-100/75">Opens billing account to restore renewal before access ends.</p> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function LessonsLibraryHero({ title, description, items = [], totalCount, pricingUrl, coursesUrl, promptLibraryUrl, academyAccess = null, analytics }) {
|
||||
const featuredLesson = items.find((item) => lessonPreviewAsset(item)) || items[0] || null
|
||||
const featuredPreview = lessonPreviewAsset(featuredLesson)
|
||||
const showSignedInAccess = Boolean(academyAccess?.signedIn)
|
||||
const accessHeading = academyAccessHeading(academyAccess)
|
||||
const accessMeta = academyAccessMeta(academyAccess)
|
||||
const useBillingAction = showSignedInAccess && academyAccess?.hasPaidAccess && academyAccess?.billingUrl
|
||||
const primaryActionLabel = useBillingAction ? (academyAccess?.status === 'grace_period' ? 'Renew now' : 'Manage billing') : 'See plans'
|
||||
const primaryActionIcon = useBillingAction ? (academyAccess?.status === 'grace_period' ? 'fa-solid fa-rotate-right' : 'fa-solid fa-sliders') : 'fa-solid fa-arrow-up-right-from-square'
|
||||
const primaryActionHref = useBillingAction ? academyAccess.billingUrl : pricingUrl
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (!useBillingAction) {
|
||||
trackUpgradeClick(analytics, { source: 'lessons_library_hero_primary' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden rounded-[40px] border border-amber-200/12 bg-[linear-gradient(155deg,rgba(251,191,36,0.14),rgba(15,23,42,0.96)_36%,rgba(14,165,233,0.14))] p-5 shadow-[0_32px_96px_rgba(2,6,23,0.32)] md:p-6 xl:p-7">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_14%,rgba(253,230,138,0.14),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,24px_24px,24px_24px] opacity-75" />
|
||||
<div className="absolute -left-8 top-12 h-36 w-36 rounded-full bg-amber-300/18 blur-3xl" />
|
||||
<div className="absolute -right-10 bottom-0 h-40 w-40 rounded-full bg-sky-300/14 blur-3xl" />
|
||||
|
||||
<div className="relative grid gap-5 xl:grid-cols-[minmax(0,1.1fr)_360px] xl:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-amber-200/18 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-amber-50/90">Skinbase AI Academy</span>
|
||||
<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-200">Lessons</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">{totalCount || 0} tutorials</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-start justify-between gap-4">
|
||||
<div className="max-w-4xl">
|
||||
<h1 className="max-w-[13ch] text-4xl font-semibold tracking-[-0.05em] text-white md:max-w-[14ch] md:text-[4.2rem] xl:max-w-[15ch] xl:text-[4.5rem]">{title}</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-200/95">{description}</p>
|
||||
</div>
|
||||
<span className="hidden h-20 w-20 shrink-0 items-center justify-center rounded-[26px] border border-white/10 bg-white/[0.04] text-2xl text-amber-50 shadow-[0_18px_40px_rgba(2,6,23,0.22)] md:flex">
|
||||
<i className="fa-solid fa-book-open-reader" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/35 px-5 py-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100/75">Library</p>
|
||||
<p className="mt-2 text-lg font-semibold leading-8 text-white">{totalCount || 0} structured lessons</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/35 px-5 py-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100/75">Focus</p>
|
||||
<p className="mt-2 text-lg font-semibold leading-8 text-white">Prompt craft + workflow cleanup</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2.5">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-amber-50/90">Short wins</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-amber-50/90">Creative habits</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-amber-50/90">Practical steps</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link href={coursesUrl} className="inline-flex items-center gap-2 rounded-full border border-amber-200/26 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-50 transition hover:border-amber-200/36 hover:bg-amber-300/18">
|
||||
<i className="fa-solid fa-route text-xs" />
|
||||
Browse courses
|
||||
</Link>
|
||||
<Link href={promptLibraryUrl || '/academy/prompts'} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white/90 transition hover:bg-white/[0.1]">
|
||||
<i className="fa-solid fa-wand-magic-sparkles text-xs" />
|
||||
Prompt library
|
||||
</Link>
|
||||
<Link href={primaryActionHref} onClick={handlePrimaryAction} className="inline-flex items-center gap-2 rounded-full border border-sky-200/20 bg-sky-300/10 px-5 py-3 text-sm font-semibold text-sky-50 transition hover:border-sky-200/30 hover:bg-sky-300/16">
|
||||
<i className={`${primaryActionIcon} text-xs`} />
|
||||
{primaryActionLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-4 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm md:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Latest lesson</p>
|
||||
{featuredLesson?.difficulty ? <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-white/80">{featuredLesson.difficulty}</span> : null}
|
||||
</div>
|
||||
<Link href={featuredLesson ? academyHref('lessons', featuredLesson.slug) : coursesUrl} className="group mt-4 block overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04] transition hover:border-amber-200/24 hover:bg-white/[0.06]">
|
||||
<div className="relative aspect-[16/10] overflow-hidden bg-[linear-gradient(135deg,rgba(251,191,36,0.18),rgba(17,24,39,0.94))]">
|
||||
{featuredPreview ? <img src={featuredPreview.src} alt="" aria-hidden="true" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.78))]" />
|
||||
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||
{featuredLesson?.formatted_lesson_number ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{featuredLesson.formatted_lesson_number}</span> : null}
|
||||
{featuredLesson ? <LockBadge item={featuredLesson} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{String(featuredLesson?.series_name || featuredLesson?.category?.name || 'Academy lesson').trim()}</p>
|
||||
<h3 className="mt-2 text-lg font-semibold tracking-[-0.03em] text-white transition group-hover:text-amber-50">{featuredLesson?.title || 'Explore lessons'}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-300">{featuredLesson?.excerpt || featuredLesson?.content_preview || featuredLesson?.description || 'Open a practical Academy lesson.'}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-2">
|
||||
<div className="rounded-[28px] border border-sky-300/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.16),rgba(15,23,42,0.82))] p-4 shadow-[0_18px_45px_rgba(2,6,23,0.2)] md:p-5 lg:p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-sky-300/25 bg-sky-300/12 text-sky-100"><i className="fa-solid fa-crown text-sm" /></span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-100/80">{showSignedInAccess ? 'Your Academy access' : 'Upgrade for full access'}</p>
|
||||
<p className="mt-1 max-w-4xl text-lg font-semibold text-white md:text-[1.85rem] md:leading-[1.45]">{showSignedInAccess ? accessHeading : 'Unlock the full lesson library, premium workflows, and the broader Academy learning track.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{showSignedInAccess ? (
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{accessMeta.map((item) => (
|
||||
<div key={item.label} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 md:px-5 md:py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/70">{item.label}</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white md:text-base">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Link href={primaryActionHref} onClick={handlePrimaryAction} className="inline-flex items-center gap-2 rounded-full border border-sky-200/30 bg-sky-200/15 px-5 py-2.5 text-sm font-semibold text-sky-50 transition hover:bg-sky-200/22">
|
||||
<i className={`${primaryActionIcon} text-xs`} />
|
||||
{primaryActionLabel}
|
||||
</Link>
|
||||
<Link href={coursesUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-5 py-2.5 text-sm font-semibold text-white/90 transition hover:bg-white/[0.1]">
|
||||
<i className="fa-solid fa-route text-xs" />
|
||||
Browse courses
|
||||
</Link>
|
||||
</div>
|
||||
{academyAccess?.status === 'grace_period' ? <p className="mt-2 text-xs text-sky-100/75">Opens billing account to restore renewal before access ends.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function currentPeriodStatSuffix(popularPeriod) {
|
||||
if (!popularPeriod?.label) {
|
||||
return 'this month'
|
||||
}
|
||||
|
||||
return popularPeriod.label === '30 days' ? 'this month' : `for ${popularPeriod.label.toLowerCase()}`
|
||||
}
|
||||
|
||||
function AcademyCard({ pageType, item, analytics, searchContext, position }) {
|
||||
const lessonSeries = String(item?.series_name || '').trim()
|
||||
const promptPreviewImage = item?.preview_image_thumb || item?.preview_image || ''
|
||||
const promptPreviewSrcSet = item?.preview_image_srcset || ''
|
||||
const lessonPreview = lessonPreviewAsset(item)
|
||||
const contentType = searchResultContentType(pageType)
|
||||
const href = itemHref(pageType, item)
|
||||
const trackSearchClick = () => {
|
||||
@@ -191,7 +592,7 @@ function AcademyCard({ pageType, item, analytics, searchContext, position }) {
|
||||
{promptPreviewImage ? <img src={promptPreviewImage} srcSet={promptPreviewSrcSet || undefined} 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}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.72))]" />
|
||||
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||
<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</span>
|
||||
<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]">{item?.ranking?.rank ? `#${item.ranking.rank} this month` : 'Prompt template'}</span>
|
||||
<LockBadge item={item} />
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 right-4 flex flex-wrap items-end justify-between gap-3">
|
||||
@@ -209,6 +610,47 @@ function AcademyCard({ pageType, item, analytics, searchContext, position }) {
|
||||
</div>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white transition group-hover:text-sky-100">{item.title}</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.excerpt || item.description || item.prompt_preview || 'No description yet.'}</p>
|
||||
{item?.ranking ? <p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-400">{item.ranking.prompt_copies > 0 ? `${item.ranking.prompt_copies} copies` : `${item.ranking.views} views`} · popularity {item.ranking.popularity_score}</p> : null}
|
||||
{item.tags?.length ? <p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{item.tags.slice(0, 4).join(' · ')}</p> : null}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
if (pageType === 'lessons') {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={trackSearchClick}
|
||||
data-academy-content-type={contentType || undefined}
|
||||
data-academy-content-id={item?.id || undefined}
|
||||
data-academy-search-query={searchContext?.query || undefined}
|
||||
data-academy-search-results-count={searchContext?.resultsCount || undefined}
|
||||
data-academy-search-position={position || undefined}
|
||||
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-amber-200/24 hover:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(10,15,26,0.98))]"
|
||||
>
|
||||
<div className="relative aspect-[16/10] overflow-hidden bg-[linear-gradient(135deg,rgba(251,191,36,0.18),rgba(17,24,39,0.94))]">
|
||||
{lessonPreview ? <img src={lessonPreview.src} alt="" aria-hidden="true" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.72))]" />
|
||||
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||
{item?.formatted_lesson_number ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{item.formatted_lesson_number}</span> : null}
|
||||
<LockBadge item={item} />
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 right-4 flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item?.difficulty ? <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}</span> : null}
|
||||
{item?.reading_minutes ? <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.reading_minutes} min</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">{item?.category?.name || 'Academy lesson'}</p>
|
||||
{lessonSeries ? <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">{lessonSeries}</span> : null}
|
||||
</div>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white transition group-hover:text-amber-50">{item.title}</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.excerpt || item.description || item.content_preview || 'No description yet.'}</p>
|
||||
{item.tags?.length ? <p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{item.tags.slice(0, 4).join(' · ')}</p> : null}
|
||||
</div>
|
||||
</Link>
|
||||
@@ -261,7 +703,7 @@ async function fetchAcademyPage(url) {
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export default function AcademyList({ pageType, title, description, seo, items, filters, categories, pricingUrl, analytics }) {
|
||||
export default function AcademyList({ pageType, promptView = 'library', title, description, seo, breadcrumbs = [], items, filters, categories, pricingUrl, coursesUrl, packsUrl, promptPopularUrl, promptLibraryUrl, popularPeriod = null, popularPeriods = [], featuredPrompts = [], popularPrompts = [], academyAccess = null, analytics }) {
|
||||
const flash = usePage().props.flash || {}
|
||||
useAcademyPageAnalytics(analytics)
|
||||
const searchContext = analytics?.search ? {
|
||||
@@ -280,6 +722,11 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
||||
})
|
||||
const [loadingMore, setLoadingMore] = React.useState(false)
|
||||
const sentinelRef = React.useRef(null)
|
||||
const hasActivePromptFilters = pageType === 'prompts' && promptView === 'library' && Boolean(filters?.q || filters?.category || filters?.difficulty || filters?.tag)
|
||||
const showPromptDiscovery = pageType === 'prompts' && promptView === 'library' && !hasActivePromptFilters
|
||||
const showPopularFeatured = pageType === 'prompts' && promptView === 'popular' && featuredPrompts.length > 0
|
||||
const infiniteLoadLabel = pageType === 'lessons' ? 'lessons' : 'prompts'
|
||||
const usesInfiniteLoad = (pageType === 'prompts' && promptView === 'library') || pageType === 'lessons'
|
||||
|
||||
React.useEffect(() => {
|
||||
setVisibleItems(initialItems)
|
||||
@@ -292,11 +739,11 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
||||
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 hasMorePages = usesInfiniteLoad && pagination.currentPage < pagination.lastPage && Boolean(pagination.nextPageUrl)
|
||||
const hasFallbackPagination = usesInfiniteLoad && pagination.lastPage > 1
|
||||
|
||||
const loadMore = React.useCallback(async () => {
|
||||
if (pageType !== 'prompts' || loadingMore || !pagination.nextPageUrl) {
|
||||
if (!usesInfiniteLoad || loadingMore || !pagination.nextPageUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -318,7 +765,7 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [loadingMore, pageType, pagination.currentPage, pagination.lastPage, pagination.nextPageUrl, pagination.prevPageUrl])
|
||||
}, [loadingMore, pagination.currentPage, pagination.lastPage, pagination.nextPageUrl, pagination.prevPageUrl, usesInfiniteLoad])
|
||||
|
||||
React.useEffect(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
@@ -343,7 +790,9 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
||||
<SeoHead seo={seo || {}} title={title} description={description} />
|
||||
|
||||
<div className="mx-auto max-w-[1360px] space-y-6">
|
||||
{pageType === 'prompts' ? <PromptLibraryHero title={title} description={description} items={visibleItems} pricingUrl={pricingUrl} totalCount={Number(items?.total || visibleItems.length || 0)} /> : (
|
||||
{(pageType === 'prompts' || pageType === 'lessons') ? <Breadcrumbs items={breadcrumbs} /> : null}
|
||||
|
||||
{pageType === 'prompts' ? <PromptLibraryHero promptView={promptView} title={title} description={description} items={visibleItems} pricingUrl={pricingUrl} coursesUrl={coursesUrl} packsUrl={packsUrl} promptPopularUrl={promptPopularUrl} promptLibraryUrl={promptLibraryUrl} popularPeriod={popularPeriod} popularPeriods={popularPeriods} totalCount={Number(items?.total || visibleItems.length || 0)} analytics={analytics} hasPopularSection={popularPrompts.length > 0} academyAccess={academyAccess} /> : pageType === 'lessons' ? <LessonsLibraryHero title={title} description={description} items={visibleItems} totalCount={Number(items?.total || visibleItems.length || 0)} pricingUrl={pricingUrl} coursesUrl={coursesUrl} promptLibraryUrl={promptLibraryUrl} academyAccess={academyAccess} analytics={analytics} /> : (
|
||||
<section className="rounded-[38px] border border-white/10 bg-black/20 p-8 md:p-10">
|
||||
<div className="flex flex-wrap items-end justify-between gap-5">
|
||||
<div>
|
||||
@@ -359,7 +808,16 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
||||
{flash.success ? <div className="rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||
{flash.error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||
|
||||
<QueryFilters pageType={pageType} filters={filters} categories={categories} />
|
||||
{promptView === 'library' ? <QueryFilters pageType={pageType} filters={filters} categories={categories} /> : null}
|
||||
|
||||
{showPromptDiscovery ? (
|
||||
<>
|
||||
<PromptDiscoverySection id="popular-prompts" title="Popular prompts right now" description="See which prompt templates are getting the most momentum from views and copies this month." items={popularPrompts} href={promptPopularUrl} ctaLabel="Open rankings" />
|
||||
<PromptDiscoverySection id="featured-prompts" title="Featured prompt picks" description="Hand-picked templates worth starting from if you want quick wins for wallpapers, worlds, portraits, and creator-style visuals." items={featuredPrompts} href={coursesUrl} ctaLabel="Browse courses" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{showPopularFeatured ? <PromptDiscoverySection id="featured-prompts" title="Featured picks to try next" description="Once you have reviewed the top-performing prompts, jump into a few curated templates that are worth adapting into your own workflow." items={featuredPrompts} href={promptLibraryUrl} ctaLabel="Browse full library" /> : null}
|
||||
|
||||
{visibleItems.length === 0 ? (
|
||||
<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.</section>
|
||||
@@ -369,11 +827,11 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
||||
{visibleItems.map((item, index) => <AcademyCard key={`${pageType}-${item.id}`} pageType={pageType} item={item} analytics={analytics} searchContext={searchContext} position={index + 1} />)}
|
||||
</section>
|
||||
|
||||
{pageType === 'prompts' ? (
|
||||
{usesInfiniteLoad ? (
|
||||
<div className="pt-2">
|
||||
<div ref={sentinelRef} className="h-10 w-full" aria-hidden="true" />
|
||||
{loadingMore ? <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...</div> : null}
|
||||
{!hasMorePages && visibleItems.length > initialItems.length ? <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.</div> : null}
|
||||
{loadingMore ? <div className="rounded-[22px] border border-white/10 bg-black/20 px-5 py-4 text-center text-sm text-slate-300">Loading more {infiniteLoadLabel}...</div> : null}
|
||||
{!hasMorePages && visibleItems.length > initialItems.length ? <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 {pageType === 'lessons' ? 'lesson library' : 'prompt library'}.</div> : null}
|
||||
{hasFallbackPagination ? (
|
||||
<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">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Auto-load is primary. Pagination is available as a backup.</div>
|
||||
|
||||
@@ -681,6 +681,54 @@ function PromptVariantCard({ variant, analytics, contentId }) {
|
||||
function PromptVariantsSection({ variants, analytics, contentId }) {
|
||||
const visibleVariants = Array.isArray(variants) ? variants.filter((variant) => variant && typeof variant === 'object') : []
|
||||
const [activeVariantKey, setActiveVariantKey] = useState('')
|
||||
const variantsScrollRef = useRef(null)
|
||||
const [canScrollVariantsLeft, setCanScrollVariantsLeft] = useState(false)
|
||||
const [canScrollVariantsRight, setCanScrollVariantsRight] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const updateVariantScrollState = () => {
|
||||
const element = variantsScrollRef.current
|
||||
if (!element) {
|
||||
setCanScrollVariantsLeft(false)
|
||||
setCanScrollVariantsRight(false)
|
||||
return
|
||||
}
|
||||
|
||||
const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth)
|
||||
setCanScrollVariantsLeft(element.scrollLeft > 6)
|
||||
setCanScrollVariantsRight(element.scrollLeft < maxScrollLeft - 6)
|
||||
}
|
||||
|
||||
updateVariantScrollState()
|
||||
|
||||
const element = variantsScrollRef.current
|
||||
if (!element) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
element.addEventListener('scroll', updateVariantScrollState, { passive: true })
|
||||
window.addEventListener('resize', updateVariantScrollState, { passive: true })
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('scroll', updateVariantScrollState)
|
||||
window.removeEventListener('resize', updateVariantScrollState)
|
||||
}
|
||||
}, [visibleVariants.length])
|
||||
|
||||
const scrollVariants = (direction) => {
|
||||
const element = variantsScrollRef.current
|
||||
if (!element) return
|
||||
|
||||
const amount = Math.max(260, Math.floor(element.clientWidth * 0.7))
|
||||
element.scrollBy({
|
||||
left: direction === 'left' ? -amount : amount,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!visibleVariants.length) {
|
||||
@@ -712,8 +760,34 @@ function PromptVariantsSection({ variants, analytics, contentId }) {
|
||||
<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.</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 overflow-x-auto pb-2">
|
||||
<div className="inline-flex min-w-full gap-3" role="tablist" aria-label="Prompt variants">
|
||||
<div className="relative mt-6">
|
||||
<div className={`pointer-events-none absolute inset-y-0 left-0 z-10 w-14 bg-gradient-to-r from-[#182233] via-[#182233]/85 to-transparent transition ${canScrollVariantsLeft ? 'opacity-100' : 'opacity-0'}`} aria-hidden="true" />
|
||||
<div className={`pointer-events-none absolute inset-y-0 right-0 z-10 w-14 bg-gradient-to-l from-[#182233] via-[#182233]/85 to-transparent transition ${canScrollVariantsRight ? 'opacity-100' : 'opacity-0'}`} aria-hidden="true" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll prompt variants left"
|
||||
onClick={() => scrollVariants('left')}
|
||||
className={`absolute left-2 top-1/2 z-20 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/12 bg-slate-950/80 text-white/80 shadow-[0_16px_36px_rgba(2,6,23,0.28)] backdrop-blur transition ${canScrollVariantsLeft ? 'opacity-100 hover:scale-105 hover:bg-slate-900/95' : 'pointer-events-none opacity-0'}`}
|
||||
>
|
||||
<i className="fa-solid fa-chevron-left text-sm" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll prompt variants right"
|
||||
onClick={() => scrollVariants('right')}
|
||||
className={`absolute right-2 top-1/2 z-20 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/12 bg-slate-950/80 text-white/80 shadow-[0_16px_36px_rgba(2,6,23,0.28)] backdrop-blur transition ${canScrollVariantsRight ? 'opacity-100 hover:scale-105 hover:bg-slate-900/95' : 'pointer-events-none opacity-0'}`}
|
||||
>
|
||||
<i className="fa-solid fa-chevron-right text-sm" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={variantsScrollRef}
|
||||
className="flex gap-3 overflow-x-auto px-1 pb-3 pt-1 snap-x snap-mandatory scroll-smooth scrollbar-hide [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="tablist"
|
||||
aria-label="Prompt variants"
|
||||
>
|
||||
{visibleVariants.map((variant, index) => {
|
||||
const variantKey = String(variant?.slug || variant?.title || `variant-${index}`)
|
||||
const isActive = activeVariant === variant
|
||||
@@ -726,7 +800,7 @@ function PromptVariantsSection({ variants, analytics, contentId }) {
|
||||
aria-selected={isActive}
|
||||
onClick={() => setActiveVariantKey(variantKey)}
|
||||
className={[
|
||||
'min-w-[220px] rounded-[24px] border px-4 py-3 text-left transition',
|
||||
'w-[min(360px,calc(100vw-4.5rem))] shrink-0 snap-start rounded-[24px] border px-4 py-3 text-left transition sm:w-[320px]',
|
||||
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]',
|
||||
@@ -1024,8 +1098,6 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
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 || ''
|
||||
@@ -1471,33 +1543,43 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
|
||||
{pageType === 'lesson' ? (
|
||||
<div className="space-y-8">
|
||||
<section className="overflow-hidden rounded-[40px] border border-white/10 bg-black/20 shadow-[0_24px_90px_rgba(15,23,42,0.34)]">
|
||||
<div className="grid gap-0 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="relative overflow-hidden p-8 md:p-10 lg:p-12">
|
||||
{lessonCover ? <img src={lessonCover} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover opacity-15" /> : null}
|
||||
<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))]" />
|
||||
<div className="relative z-10 max-w-3xl">
|
||||
<section className="relative overflow-hidden rounded-[40px] border border-sky-200/12 bg-[linear-gradient(145deg,rgba(14,165,233,0.16),rgba(15,23,42,0.95)_38%,rgba(251,191,36,0.14))] shadow-[0_24px_90px_rgba(15,23,42,0.34)]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_14%,rgba(125,211,252,0.14),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,24px_24px,24px_24px] opacity-70" />
|
||||
<div className="absolute -left-8 top-10 h-36 w-36 rounded-full bg-sky-300/18 blur-3xl" />
|
||||
<div className="absolute -right-10 bottom-0 h-40 w-40 rounded-full bg-amber-300/14 blur-3xl" />
|
||||
|
||||
<div className="relative grid gap-6 p-5 md:p-6 lg:grid-cols-[minmax(0,1fr)_360px] lg:p-7">
|
||||
<div className="relative overflow-hidden rounded-[34px] border border-white/10 bg-[linear-gradient(135deg,rgba(2,6,23,0.86),rgba(15,23,42,0.62))] p-5 shadow-[0_20px_46px_rgba(2,6,23,0.18)] md:p-6 lg:p-7">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(135deg,rgba(2,6,23,0.86),rgba(15,23,42,0.62))]" />
|
||||
<div className="relative z-10 max-w-4xl">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<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</span>
|
||||
<span className="rounded-full border border-sky-200/18 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-50/90">Skinbase AI Academy</span>
|
||||
<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-200">Lesson</span>
|
||||
<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}</span>
|
||||
<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}</span>
|
||||
</div>
|
||||
|
||||
{item.lesson_label ? <p className="mt-5 text-sm font-semibold uppercase tracking-[0.24em] text-amber-100">{item.lesson_label}</p> : null}
|
||||
|
||||
<h1 className="mt-5 text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl lg:text-6xl">{item.title}</h1>
|
||||
<p className="mt-5 max-w-2xl text-base leading-8 text-slate-300 md:text-lg">{lessonSummary}</p>
|
||||
<div className="mt-5 flex items-start justify-between gap-4">
|
||||
<div className="max-w-3xl">
|
||||
{item.lesson_label ? <p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-100">{item.lesson_label}</p> : null}
|
||||
<h1 className="mt-4 text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl lg:text-[3.8rem]">{item.title}</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-200/95 md:text-lg">{lessonSummary}</p>
|
||||
</div>
|
||||
<span className="hidden h-20 w-20 shrink-0 items-center justify-center rounded-[26px] border border-white/10 bg-white/[0.04] text-2xl text-sky-50 shadow-[0_18px_40px_rgba(2,6,23,0.22)] md:flex">
|
||||
<i className="fa-solid fa-book-open-reader" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{lessonTags.length ? (
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<div className="mt-5 flex flex-wrap gap-2.5">
|
||||
{lessonTags.map((tag) => (
|
||||
<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}</span>
|
||||
<span key={tag} className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-sky-50/90">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{courseContext?.title ? (
|
||||
<div className="mt-6 max-w-2xl rounded-[24px] border border-white/10 bg-black/25 p-5">
|
||||
<div className="mt-6 max-w-2xl rounded-[24px] border border-white/10 bg-slate-950/35 p-5 backdrop-blur-sm">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Part of course</p>
|
||||
<Link href={courseContext.showUrl} className="mt-2 inline-flex text-lg font-semibold text-sky-100 transition hover:text-white">{courseContext.title}</Link>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-300">{courseContext.subtitle || 'This lesson is being viewed inside a structured Academy course path.'}</p>
|
||||
@@ -1520,13 +1602,19 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="border-t border-white/10 bg-white/[0.03] p-6 lg:border-l lg:border-t-0 lg:p-8">
|
||||
<div className="space-y-5 lg:sticky lg:top-6">
|
||||
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-black/20">
|
||||
{lessonCover ? <img src={lessonCover} alt={item.title} className="h-52 w-full object-cover" /> : <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</div>}
|
||||
<aside className="grid gap-4 self-start">
|
||||
<div className="relative overflow-hidden rounded-[32px] border border-white/10 bg-slate-950 shadow-[0_24px_56px_rgba(2,6,23,0.24)]">
|
||||
{lessonCover ? <img src={lessonCover} alt={item.title} className="h-[260px] w-full object-cover sm:h-[300px] lg:h-[320px]" /> : <div className="flex h-[260px] items-center justify-center bg-[linear-gradient(135deg,_rgba(14,165,233,0.22),_rgba(17,24,39,0.96))] text-sm font-semibold uppercase tracking-[0.24em] text-slate-300 sm:h-[300px] lg:h-[320px]">Lesson cover</div>}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.02),rgba(2,6,23,0.2)_48%,rgba(2,6,23,0.88))]" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-4">
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/30 px-4 py-3 backdrop-blur-md">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-sky-100/80">Lesson cover</p>
|
||||
<p className="mt-1 text-sm font-semibold text-white">{item.lesson_label || item.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 rounded-[30px] border border-white/10 bg-black/20 p-5 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm">
|
||||
<LessonInfoRow label="Series" value={lessonSeries} />
|
||||
{item.formatted_lesson_number ? <LessonInfoRow label="Lesson" value={item.formatted_lesson_number} /> : null}
|
||||
<LessonInfoRow label="Difficulty" value={lessonDifficulty} />
|
||||
@@ -1534,11 +1622,10 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
<LessonInfoRow label="Published" value={lessonUpdated} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<div className="rounded-[30px] border border-white/10 bg-slate-950/35 p-5 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Lesson status</p>
|
||||
<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.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1731,53 +1818,13 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
</div>
|
||||
) : pageType === 'prompt' ? (
|
||||
<div className="space-y-8">
|
||||
<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)]">
|
||||
<div className="grid gap-0 lg:grid-cols-[minmax(340px,0.8fr)_minmax(0,1.2fr)]">
|
||||
<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">
|
||||
<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%)]" />
|
||||
<div className="relative flex h-full flex-col">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]">Preview artwork</p>
|
||||
{promptPreviewImage ? <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</span> : null}
|
||||
</div>
|
||||
<section className="relative overflow-hidden rounded-[40px] border border-rose-200/12 bg-[linear-gradient(150deg,rgba(244,63,94,0.14),rgba(15,23,42,0.96)_36%,rgba(45,212,191,0.14))] shadow-[0_24px_90px_rgba(15,23,42,0.34)]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_14%,rgba(251,113,133,0.15),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,24px_24px,24px_24px] opacity-75" />
|
||||
<div className="absolute -left-8 top-10 h-36 w-36 rounded-full bg-rose-300/16 blur-3xl" />
|
||||
<div className="absolute -right-10 bottom-0 h-40 w-40 rounded-full bg-cyan-300/14 blur-3xl" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPromptPreviewImage}
|
||||
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 ? (
|
||||
<div className="relative h-full min-h-[320px] overflow-hidden lg:min-h-[540px]">
|
||||
<img src={promptPreviewThumbImage} srcSet={promptPreviewSrcSet || undefined} 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]" />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.02),rgba(2,6,23,0.28))]" />
|
||||
<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">
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-sky-100/80">Prompt visual</p>
|
||||
<p className="mt-1 text-sm font-semibold text-white">Open full-size preview</p>
|
||||
</div>
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/10 text-white">
|
||||
<i className="fa-solid fa-expand" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Visual placeholder</p>
|
||||
<p className="mt-4 text-lg font-semibold text-white">Preview image coming soon</p>
|
||||
<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.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden p-6 md:p-8 lg:p-9">
|
||||
<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%)]" />
|
||||
<div className="relative z-10 max-w-3xl">
|
||||
<div className="relative grid gap-6 p-5 md:p-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)] lg:p-7">
|
||||
<div className="min-w-0">
|
||||
{academyBreadcrumbs.length ? (
|
||||
<div className="mb-5">
|
||||
<AcademyBreadcrumbs items={academyBreadcrumbs} />
|
||||
@@ -1785,7 +1832,8 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<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</span>
|
||||
<span className="rounded-full border border-rose-200/18 bg-rose-300/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-rose-50/90">Skinbase AI Academy</span>
|
||||
<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-200">Prompt Library</span>
|
||||
<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}</span>
|
||||
<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}</span>
|
||||
{item.aspect_ratio ? <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}</span> : null}
|
||||
@@ -1793,9 +1841,16 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
{item.featured ? <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</span> : null}
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-xs font-semibold uppercase tracking-[0.22em] text-[#ffd8cd]">Prompt template</p>
|
||||
<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}</h1>
|
||||
<p className="mt-4 max-w-2xl text-[15px] leading-7 text-slate-300 md:text-base">{lessonSummary}</p>
|
||||
<div className="mt-5 flex items-start justify-between gap-4">
|
||||
<div className="max-w-4xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-rose-100/80">Prompt template</p>
|
||||
<h1 className="mt-3 max-w-[13ch] text-[clamp(2.6rem,5vw,4.8rem)] font-semibold leading-[0.95] tracking-[-0.05em] text-white">{item.title}</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-200/95">{lessonSummary}</p>
|
||||
</div>
|
||||
<span className="hidden h-20 w-20 shrink-0 items-center justify-center rounded-[26px] border border-white/10 bg-white/[0.04] text-2xl text-rose-50 shadow-[0_18px_40px_rgba(2,6,23,0.22)] md:flex">
|
||||
<i className="fa-solid fa-wand-magic-sparkles" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<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}`}</button>
|
||||
@@ -1835,45 +1890,47 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
/>
|
||||
</div>
|
||||
|
||||
{lessonTags.length ? (
|
||||
<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">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Microtags</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{lessonTags.map((tag) => (
|
||||
<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}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-7 grid gap-4 xl:grid-cols-[minmax(0,1.08fr)_minmax(260px,0.92fr)]">
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-4 md:p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Prompt status</p>
|
||||
<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.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{promptModelsCovered.length ? (
|
||||
<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">
|
||||
<div className="grid gap-4 lg:pt-2">
|
||||
<div className="flex h-full flex-col rounded-[30px] border border-white/10 bg-black/20 p-4 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm md:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-rose-100/80">Preview artwork</p>
|
||||
{promptPreviewImage ? <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</span> : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPromptPreviewImage}
|
||||
className="group mt-4 flex min-h-[420px] flex-1 flex-col overflow-hidden rounded-[28px] 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 lg:min-h-[640px]"
|
||||
disabled={!promptPreviewImage}
|
||||
aria-label={promptPreviewImage ? `Open preview image for ${item.title}` : 'Preview image unavailable'}
|
||||
>
|
||||
{promptPreviewImage ? (
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<img src={promptPreviewImage || promptPreviewThumbImage} srcSet={promptPreviewSrcSet || undefined} sizes="(max-width: 1023px) calc(100vw - 3rem), 34vw" alt={item.title} className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]" />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.02),rgba(2,6,23,0.36))]" />
|
||||
<div className="absolute bottom-4 left-4 right-4 flex items-end justify-between gap-4 rounded-[24px] border border-white/10 bg-black/30 px-4 py-3 backdrop-blur-md">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]">Compared with</p>
|
||||
<p className="mt-2 text-sm text-slate-300">{promptModelsCovered.length} model{promptModelsCovered.length > 1 ? 's' : ''} documented for this prompt.</p>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-sky-100/80">Prompt visual</p>
|
||||
<p className="mt-1 text-sm font-semibold text-white">Open full-size preview</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold text-white">{promptModelsCovered.length}</span>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{promptModelsCovered.map((model) => (
|
||||
<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}</span>
|
||||
))}
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/10 text-white">
|
||||
<i className="fa-solid fa-expand" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center bg-[linear-gradient(135deg,rgba(251,146,60,0.14),rgba(17,24,39,0.96))] px-8 text-center">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Visual placeholder</p>
|
||||
<p className="mt-4 text-lg font-semibold text-white">Preview image coming soon</p>
|
||||
<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.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -7,7 +7,16 @@ function MetricCell({ value, suffix = '' }) {
|
||||
return <span className="font-semibold text-white">{value}{suffix}</span>
|
||||
}
|
||||
|
||||
export default function AcademyAnalyticsContent({ nav = [], range, title, subtitle, rows = [] }) {
|
||||
function StatCard({ label, value, suffix = '' }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</p>
|
||||
<p className="mt-3 text-3xl font-bold text-white">{value}{suffix}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyAnalyticsContent({ nav = [], range, title, subtitle, summary = null, rows = [] }) {
|
||||
return (
|
||||
<AdminLayout title={title} subtitle={subtitle}>
|
||||
<Head title={`Admin · ${title}`} />
|
||||
@@ -20,6 +29,19 @@ export default function AcademyAnalyticsContent({ nav = [], range, title, subtit
|
||||
<p className="mt-3 text-sm text-slate-300">{range?.from} to {range?.to}</p>
|
||||
</div>
|
||||
|
||||
{summary ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard label="Views" value={Number(summary.views || 0).toLocaleString()} />
|
||||
<StatCard label="Unique Visitors" value={Number(summary.uniqueVisitors || 0).toLocaleString()} />
|
||||
<StatCard label="Engaged Views" value={Number(summary.engagedViews || 0).toLocaleString()} />
|
||||
<StatCard label="Engagement Rate" value={Number(summary.engagementRate || 0).toLocaleString()} suffix="%" />
|
||||
<StatCard label="Avg Engaged Seconds" value={Number(summary.avgEngagedSeconds || 0).toLocaleString()} suffix="s" />
|
||||
<StatCard label="Scroll 50%" value={Number(summary.scroll50 || 0).toLocaleString()} />
|
||||
<StatCard label="Scroll 100%" value={Number(summary.scroll100 || 0).toLocaleString()} />
|
||||
<StatCard label="Deep Scroll Rate" value={Number(summary.deepScrollRate || 0).toLocaleString()} suffix="%" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-hidden rounded-[28px] border border-white/[0.08] bg-white/[0.03]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
|
||||
@@ -12,6 +12,99 @@ function StatCard({ label, value }) {
|
||||
)
|
||||
}
|
||||
|
||||
function formatDelta(delta) {
|
||||
if (delta === null || delta === undefined) {
|
||||
return 'new'
|
||||
}
|
||||
|
||||
if (Number(delta) === 0) {
|
||||
return '0%'
|
||||
}
|
||||
|
||||
return `${Number(delta) > 0 ? '+' : ''}${Number(delta).toLocaleString()}%`
|
||||
}
|
||||
|
||||
function PromptLibraryTrend({ trend }) {
|
||||
const current = trend?.current || {}
|
||||
const deltas = trend?.deltas || {}
|
||||
|
||||
const items = [
|
||||
{ label: 'Views', value: Number(current.views || 0).toLocaleString(), delta: deltas.views },
|
||||
{ label: 'Unique Visitors', value: Number(current.uniqueVisitors || 0).toLocaleString(), delta: deltas.uniqueVisitors },
|
||||
{ label: 'Engaged Views', value: Number(current.engagedViews || 0).toLocaleString(), delta: deltas.engagedViews },
|
||||
{ label: 'Engagement Rate', value: `${Number(current.engagementRate || 0).toLocaleString()}%`, delta: deltas.engagementRate },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Prompt Library Trend</p>
|
||||
<p className="mt-2 text-sm text-slate-300">{trend?.range?.current?.from} to {trend?.range?.current?.to} compared with {trend?.range?.previous?.from} to {trend?.range?.previous?.to}</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/[0.08] bg-black/20 px-4 py-2 text-xs uppercase tracking-[0.18em] text-slate-400">
|
||||
Popularity {Number(current.popularityScore || 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="rounded-2xl border border-white/[0.08] bg-black/20 p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{item.label}</p>
|
||||
<p className="mt-3 text-2xl font-bold text-white">{item.value}</p>
|
||||
<p className="mt-2 text-xs uppercase tracking-[0.18em] text-sky-200">{formatDelta(item.delta)} vs previous</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PopularPromptPeriodUsage({ usage }) {
|
||||
const periods = usage?.periods || []
|
||||
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Popular Prompt Period Usage</p>
|
||||
<p className="mt-2 text-sm text-slate-300">Which ranking window people actually open on the public popular-prompts page.</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/[0.08] bg-black/20 px-4 py-2 text-xs uppercase tracking-[0.18em] text-slate-400">
|
||||
{Number(usage?.totalViews || 0).toLocaleString()} views · {Number(usage?.totalVisitors || 0).toLocaleString()} visitors
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{periods.length ? periods.map((period) => (
|
||||
<div key={period.period} className="rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{period.label}</p>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{period.period}</p>
|
||||
</div>
|
||||
<div className="grid gap-2 text-right sm:grid-cols-3 sm:gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-sky-100">{Number(period.views || 0).toLocaleString()}</p>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Views</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-sky-100">{Number(period.uniqueVisitors || 0).toLocaleString()}</p>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Visitors</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-sky-100">{Number(period.share || 0).toLocaleString()}%</p>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Share</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)) : <p className="rounded-2xl border border-dashed border-white/[0.08] bg-black/20 px-4 py-6 text-sm text-slate-400">No popular prompt period events have been tracked in this range yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContentList({ title, items = [] }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
@@ -36,7 +129,7 @@ function ContentList({ title, items = [] }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyAnalyticsOverview({ nav = [], range, stats, topContent = [], topWeek = [] }) {
|
||||
export default function AcademyAnalyticsOverview({ nav = [], range, stats, promptLibraryTrend = null, popularPromptPeriodUsage = null, topContent = [], topWeek = [] }) {
|
||||
return (
|
||||
<AdminLayout title="Academy Analytics" subtitle="Daily rollup overview for Academy traffic, engagement, and subscription intent.">
|
||||
<Head title="Admin · Academy Analytics" />
|
||||
@@ -63,6 +156,9 @@ export default function AcademyAnalyticsOverview({ nav = [], range, stats, topCo
|
||||
<StatCard label="Upgrade Clicks" value={stats.upgradeClicks} />
|
||||
</div>
|
||||
|
||||
{promptLibraryTrend ? <PromptLibraryTrend trend={promptLibraryTrend} /> : null}
|
||||
{popularPromptPeriodUsage ? <PopularPromptPeriodUsage usage={popularPromptPeriodUsage} /> : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<ContentList title="Top Content In Range" items={topContent} />
|
||||
<ContentList title="Top Content This Week" items={topWeek} />
|
||||
|
||||
@@ -51,6 +51,38 @@ function serializeStructuredJson(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function copyTextToClipboard(text) {
|
||||
const source = String(text || '')
|
||||
if (!source) return Promise.reject(new Error('Nothing to copy'))
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
return navigator.clipboard.writeText(source)
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined' || !document.body) {
|
||||
return Promise.reject(new Error('Clipboard unavailable'))
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = source
|
||||
textarea.setAttribute('readonly', 'true')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.top = '-1000px'
|
||||
textarea.style.left = '-1000px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
|
||||
try {
|
||||
if (document.execCommand('copy')) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
|
||||
return Promise.reject(new Error('Clipboard unavailable'))
|
||||
}
|
||||
|
||||
function getField(fields, name) {
|
||||
return fields.find((field) => field.name === name) || null
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export default function Dashboard({ stats }) {
|
||||
{ label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up', desc: 'Moderate pending artwork submissions' },
|
||||
{ label: 'Stories', href: '/moderation/stories', icon: 'fa-solid fa-feather-pointed', desc: 'Browse all creator stories' },
|
||||
{ label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images', desc: 'Browse all uploaded artworks' },
|
||||
{ label: 'Enhance Jobs', href: '/moderation/enhance', icon: 'fa-solid fa-up-right-and-down-left-from-center', desc: 'Inspect queued, failed, and completed image enhance jobs' },
|
||||
{ label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star', desc: 'Curate the homepage featured artwork lineup' },
|
||||
{ label: 'AI Biography', href: '/moderation/ai-biography', icon: 'fa-solid fa-wand-magic-sparkles', desc: 'Review generated creator biographies and moderation flags' },
|
||||
].map((item) => (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
172
resources/js/Pages/Enhance/Create.jsx
Normal file
172
resources/js/Pages/Enhance/Create.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/react'
|
||||
import EnhanceStubWarning from '../../components/enhance/EnhanceStubWarning'
|
||||
|
||||
export default function EnhanceCreate() {
|
||||
const { props } = usePage()
|
||||
const form = useForm({ image: null, scale: props.options?.scales?.[0]?.value || 2, mode: props.options?.modes?.[0]?.value || 'standard' })
|
||||
const [previewUrl, setPreviewUrl] = React.useState(null)
|
||||
const [sourceType, setSourceType] = React.useState(props.selectedArtwork ? 'artwork' : 'upload')
|
||||
|
||||
React.useEffect(() => () => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl)
|
||||
}
|
||||
}, [previewUrl])
|
||||
|
||||
function handleFileChange(event) {
|
||||
const file = event.target.files?.[0] || null
|
||||
form.setData('image', file)
|
||||
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl)
|
||||
}
|
||||
|
||||
setPreviewUrl(file ? URL.createObjectURL(file) : null)
|
||||
}
|
||||
|
||||
function submit(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const action = sourceType === 'artwork' && props.selectedArtwork?.store_url
|
||||
? props.selectedArtwork.store_url
|
||||
: props.storeUrl
|
||||
|
||||
form.post(action, {
|
||||
forceFormData: sourceType !== 'artwork',
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full pb-16 pt-8">
|
||||
<Head title="Skinbase Enhance" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.18),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)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Skinbase Enhance</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Create an upscaled image</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Large images may take longer to process. The original image will stay unchanged.</p>
|
||||
</div>
|
||||
|
||||
<Link href={props.indexUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
<i className="fa-solid fa-arrow-left text-[10px]" />
|
||||
Back to jobs
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EnhanceStubWarning config={props.enhanceConfig} className="mt-6" />
|
||||
|
||||
<form onSubmit={submit} className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1.05fr)_380px]">
|
||||
<section className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Enhance source</div>
|
||||
{props.selectedArtwork ? (
|
||||
<div className="inline-flex rounded-full border border-white/10 bg-white/[0.04] p-1 text-xs font-semibold text-slate-300">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourceType('artwork')}
|
||||
className={`rounded-full px-4 py-2 transition ${sourceType === 'artwork' ? 'bg-sky-400/15 text-sky-50' : 'hover:bg-white/[0.06]'}`}
|
||||
>
|
||||
Existing artwork
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourceType('upload')}
|
||||
className={`rounded-full px-4 py-2 transition ${sourceType === 'upload' ? 'bg-sky-400/15 text-sky-50' : 'hover:bg-white/[0.06]'}`}
|
||||
>
|
||||
Upload image
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{sourceType === 'artwork' && props.selectedArtwork ? (
|
||||
<div className="mt-4 rounded-[28px] border border-sky-300/20 bg-[linear-gradient(180deg,rgba(14,165,233,0.1),rgba(8,17,29,0.9))] p-6 text-left">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Existing artwork source</div>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">{props.selectedArtwork.title}</h2>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-300">Use the current artwork source without re-uploading a file. The original artwork remains untouched and the enhanced result will be stored as a separate job output.</p>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<a href={props.selectedArtwork.show_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.08]">
|
||||
<i className="fa-solid fa-image text-[10px]" />
|
||||
View artwork
|
||||
</a>
|
||||
<span 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 uppercase tracking-[0.14em] text-emerald-100">
|
||||
<i className="fa-solid fa-lock text-[10px]" />
|
||||
Original stays unchanged
|
||||
</span>
|
||||
</div>
|
||||
{form.errors.source ? <div className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{form.errors.source}</div> : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="mt-4 flex min-h-[420px] cursor-pointer flex-col items-center justify-center rounded-[28px] border border-dashed border-white/15 bg-black/20 px-6 py-8 text-center transition hover:border-sky-300/30 hover:bg-sky-400/[0.03]">
|
||||
{previewUrl ? <img src={previewUrl} alt="Selected for enhance" className="max-h-[420px] w-full rounded-[20px] object-contain" /> : <>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full border border-white/10 bg-white/[0.05] text-white/70"><i className="fa-solid fa-cloud-arrow-up text-2xl" /></div>
|
||||
<div className="mt-4 text-lg font-semibold text-white">Choose a JPEG, PNG, or WebP image</div>
|
||||
<div className="mt-2 max-w-md text-sm text-slate-400">Upload an image up to {props.maxUploadMb} MB. SVG, GIF, and unsupported file types are rejected.</div>
|
||||
</>}
|
||||
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={handleFileChange} className="hidden" />
|
||||
</label>
|
||||
{form.errors.image ? <div className="mt-3 text-sm text-rose-300">{form.errors.image}</div> : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Enhance settings</div>
|
||||
|
||||
<div className="mt-5 space-y-5">
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-white">Scale</label>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
{(props.options?.scales || []).map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => form.setData('scale', option.value)}
|
||||
className={`rounded-2xl border px-4 py-4 text-left transition ${Number(form.data.scale) === Number(option.value) ? 'border-sky-300/30 bg-sky-400/12 text-sky-50' : 'border-white/10 bg-white/[0.04] text-slate-300 hover:bg-white/[0.08]'}`}
|
||||
>
|
||||
<div className="text-sm font-semibold">{option.label}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-current/70">Upscale size</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{form.errors.scale ? <div className="mt-2 text-sm text-rose-300">{form.errors.scale}</div> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-white">Mode</label>
|
||||
<div className="mt-3 space-y-3">
|
||||
{(props.options?.modes || []).map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => form.setData('mode', option.value)}
|
||||
className={`w-full rounded-2xl border px-4 py-4 text-left transition ${String(form.data.mode) === String(option.value) ? 'border-sky-300/30 bg-sky-400/12 text-sky-50' : 'border-white/10 bg-white/[0.04] text-slate-300 hover:bg-white/[0.08]'}`}
|
||||
>
|
||||
<div className="text-sm font-semibold">{option.label}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-current/70">Optimized preset</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{form.errors.mode ? <div className="mt-2 text-sm text-rose-300">{form.errors.mode}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-4 text-sm leading-6 text-amber-50">
|
||||
The original file is preserved separately. Completed outputs can be reviewed and downloaded before you decide how to use them.
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={form.processing} className="mt-6 inline-flex w-full items-center justify-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-3 text-sm font-semibold text-sky-50 transition hover:bg-sky-400/20 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
<i className="fa-solid fa-wand-magic-sparkles text-xs" />
|
||||
{form.processing ? 'Starting enhance…' : sourceType === 'artwork' ? 'Enhance artwork image' : 'Start enhance'}
|
||||
</button>
|
||||
</section>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
135
resources/js/Pages/Enhance/Index.jsx
Normal file
135
resources/js/Pages/Enhance/Index.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, usePage } from '@inertiajs/react'
|
||||
import EnhanceStatusBadge from '../../components/enhance/EnhanceStatusBadge'
|
||||
import EnhanceStubWarning from '../../components/enhance/EnhanceStubWarning'
|
||||
import { formatEnhanceDate, formatEnhanceInteger } from '../../utils/enhanceFormatting'
|
||||
|
||||
function formatDate(value) {
|
||||
return formatEnhanceDate(value)
|
||||
}
|
||||
|
||||
function JobCard({ job }) {
|
||||
return (
|
||||
<article className="overflow-hidden rounded-[28px] border border-white/10 bg-[#08111d] shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
|
||||
<div className="aspect-square bg-black/30">
|
||||
{job.preview_url || job.source_url ? <img src={job.preview_url || job.source_url} alt="Enhance preview" className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-white/20"><i className="fa-solid fa-image text-4xl" /></div>}
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EnhanceStatusBadge status={job.status} />
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.scale}x</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.mode}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.engine}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-4 text-2xl font-semibold tracking-[-0.03em] text-white">
|
||||
{job.artwork?.title ? `Artwork enhance: ${job.artwork.title}` : `Enhance job #${job.id}`}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">Created {formatDate(job.created_at)} {job.processing_seconds ? `• ${job.processing_seconds}s processing` : ''}</p>
|
||||
<div className="mt-2 text-sm text-slate-400">{job.input_width} × {job.input_height}{job.output_width && job.output_height ? ` → ${job.output_width} × ${job.output_height}` : ''}</div>
|
||||
|
||||
{job.error_message ? <div className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{job.error_message}</div> : null}
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<Link href={job.show_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 job</Link>
|
||||
{job.artwork?.url ? <a href={job.artwork.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 artwork</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default function EnhanceIndex() {
|
||||
const { props } = usePage()
|
||||
const jobs = props.jobs?.data || []
|
||||
const latestCompleted = props.latestCompleted || []
|
||||
const flash = props.flash || {}
|
||||
const enhanceConfig = props.enhanceConfig || {}
|
||||
|
||||
return (
|
||||
<div className="w-full pb-16 pt-8">
|
||||
<Head title="Skinbase Enhance" />
|
||||
|
||||
<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)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Skinbase Enhance</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Image Upscaler</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Improve older wallpapers, digital art, and photos with a clean upscaled version. Your original file is never replaced automatically.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={props.createUrl} className="inline-flex items-center gap-2 rounded-full 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">
|
||||
<i className="fa-solid fa-sparkles text-[10px]" />
|
||||
Start enhance
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Daily limit</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{formatEnhanceInteger(props.dailyLimit || 0)}</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Total jobs</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{formatEnhanceInteger(props.jobs?.total || jobs.length)}</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Completed outputs</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{formatEnhanceInteger(latestCompleted.length)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EnhanceStubWarning config={enhanceConfig} className="mt-6" />
|
||||
|
||||
{flash.success ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{flash.success}</div> : null}
|
||||
{flash.error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||
|
||||
{latestCompleted.length > 0 ? (
|
||||
<section className="mt-8">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Latest completed</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Recent enhanced outputs</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{latestCompleted.map((job) => (
|
||||
<Link key={job.id} href={job.show_url} className="overflow-hidden rounded-[28px] border border-white/10 bg-[#08111d] transition hover:border-sky-300/30 hover:bg-[#0b1524]">
|
||||
<div className="aspect-square bg-black/20">
|
||||
{job.output_url ? <img src={job.output_url} alt={`Enhance job ${job.id}`} className="h-full w-full object-cover" /> : null}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<EnhanceStatusBadge status={job.status} />
|
||||
<div className="mt-3 text-sm font-semibold text-white">Job #{job.id}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-400">{job.scale}x • {job.mode}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mt-8">
|
||||
<div className="mb-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">History</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Your enhance jobs</h2>
|
||||
</div>
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-300">No enhance jobs yet. Upload an image to start your first upscale.</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{jobs.map((job) => <JobCard key={job.id} job={job} />)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
resources/js/Pages/Enhance/Show.jsx
Normal file
144
resources/js/Pages/Enhance/Show.jsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react'
|
||||
import BeforeAfterSlider from '../../components/enhance/BeforeAfterSlider'
|
||||
import EnhanceStatusBadge from '../../components/enhance/EnhanceStatusBadge'
|
||||
import EnhanceStubWarning from '../../components/enhance/EnhanceStubWarning'
|
||||
import { formatEnhanceDate } from '../../utils/enhanceFormatting'
|
||||
|
||||
function formatDate(value) {
|
||||
return formatEnhanceDate(value)
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 border-b border-white/[0.06] py-3 last:border-b-0 last:pb-0">
|
||||
<dt className="text-sm text-slate-400">{label}</dt>
|
||||
<dd className="text-right text-sm text-white">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function EnhanceShow() {
|
||||
const { props } = usePage()
|
||||
const job = props.job || {}
|
||||
const flash = props.flash || {}
|
||||
const errors = props.errors || {}
|
||||
const statusKey = String(job.status || '').toLowerCase()
|
||||
const statusCopy = {
|
||||
pending: 'Waiting to be queued.',
|
||||
queued: 'Waiting for processor.',
|
||||
processing: 'Enhancing image.',
|
||||
completed: 'Enhanced image ready.',
|
||||
failed: 'Enhancement failed.',
|
||||
cancelled: 'Cancelled.',
|
||||
expired: 'Enhanced output expired and cleaned files were removed.',
|
||||
}[statusKey] || 'Unknown status.'
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!['pending', 'queued', 'processing'].includes(statusKey)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
router.reload({ only: ['job', 'flash'], preserveScroll: true })
|
||||
}, 8000)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [statusKey])
|
||||
|
||||
const canCompare = Boolean(job.source_url && job.output_url && job.status === 'completed')
|
||||
|
||||
return (
|
||||
<div className="w-full pb-16 pt-8">
|
||||
<Head title={`Enhance Job #${job.id || ''}`} />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.18),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)]">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EnhanceStatusBadge status={job.status} />
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.scale}x</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.mode}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.engine}</span>
|
||||
</div>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Enhance job #{job.id}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">{statusCopy}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={props.indexUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
<i className="fa-solid fa-arrow-left text-[10px]" />
|
||||
Back to jobs
|
||||
</Link>
|
||||
<Link href={props.createUrl} className="inline-flex items-center gap-2 rounded-full 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">
|
||||
<i className="fa-solid fa-plus text-[10px]" />
|
||||
New enhance
|
||||
</Link>
|
||||
{job.download_url ? <a href={job.download_url} className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-50 transition hover:bg-emerald-400/20">Download enhanced</a> : null}
|
||||
{job.can_retry ? <button type="button" onClick={() => router.post(job.retry_url, {}, { preserveScroll: true })} className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-amber-50 transition hover:bg-amber-400/20">Retry</button> : null}
|
||||
{job.can_delete ? <button type="button" onClick={() => {
|
||||
if (!window.confirm('Delete this enhance job and its generated files?')) return
|
||||
router.delete(job.delete_url)
|
||||
}} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/20">Delete</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EnhanceStubWarning config={props.enhanceConfig} className="mt-6" />
|
||||
|
||||
{flash.success ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{flash.success}</div> : null}
|
||||
{flash.error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||
{errors.job ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{errors.job}</div> : null}
|
||||
{job.error_message ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{job.error_message}</div> : null}
|
||||
|
||||
{canCompare ? <div className="mt-8"><BeforeAfterSlider beforeUrl={job.source_url} afterUrl={job.output_url} /></div> : null}
|
||||
|
||||
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_380px]">
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Original source</div>
|
||||
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
|
||||
{job.source_url ? <img src={job.source_url} alt="Original source" className="w-full object-cover" /> : <div className="flex min-h-[280px] items-center justify-center text-white/20"><i className="fa-solid fa-image text-4xl" /></div>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Enhanced result</div>
|
||||
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
|
||||
{job.output_url ? <img src={job.output_url} alt="Enhanced output" className="w-full object-cover" /> : <div className="flex min-h-[280px] items-center justify-center text-white/20"><i className="fa-solid fa-hourglass-half text-4xl" /></div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Metadata</div>
|
||||
<div className="mt-4 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words">{JSON.stringify(job.metadata || {}, null, 2)}</pre>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Job details</div>
|
||||
<dl className="mt-4">
|
||||
<DetailRow label="Created" value={formatDate(job.created_at)} />
|
||||
<DetailRow label="Queued" value={formatDate(job.queued_at)} />
|
||||
<DetailRow label="Started" value={formatDate(job.started_at)} />
|
||||
<DetailRow label="Finished" value={formatDate(job.finished_at)} />
|
||||
<DetailRow label="Expires" value={formatDate(job.expires_at)} />
|
||||
<DetailRow label="Input size" value={job.input_filesize ? `${(job.input_filesize / 1024 / 1024).toFixed(2)} MB` : '—'} />
|
||||
<DetailRow label="Input mime" value={job.input_mime || '—'} />
|
||||
<DetailRow label="Input dimensions" value={job.input_width && job.input_height ? `${job.input_width} × ${job.input_height}` : '—'} />
|
||||
<DetailRow label="Output size" value={job.output_filesize ? `${(job.output_filesize / 1024 / 1024).toFixed(2)} MB` : '—'} />
|
||||
<DetailRow label="Output mime" value={job.output_mime || '—'} />
|
||||
<DetailRow label="Output dimensions" value={job.output_width && job.output_height ? `${job.output_width} × ${job.output_height}` : '—'} />
|
||||
<DetailRow label="Processing seconds" value={job.processing_seconds ?? '—'} />
|
||||
<DetailRow label="Artwork" value={job.artwork?.title ? <a href={job.artwork.url} className="text-sky-300 hover:text-sky-200">{job.artwork.title}</a> : 'Standalone upload'} />
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
resources/js/Pages/Moderation/Enhance/Index.jsx
Normal file
110
resources/js/Pages/Moderation/Enhance/Index.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react'
|
||||
import EnhanceStatusBadge from '../../../components/enhance/EnhanceStatusBadge'
|
||||
import EnhanceStubWarning from '../../../components/enhance/EnhanceStubWarning'
|
||||
import { formatEnhanceDate } from '../../../utils/enhanceFormatting'
|
||||
|
||||
function formatDate(value) {
|
||||
return formatEnhanceDate(value)
|
||||
}
|
||||
|
||||
export default function ModerationEnhanceIndex() {
|
||||
const { props } = usePage()
|
||||
const [filters, setFilters] = React.useState(props.filters || {})
|
||||
const jobs = props.jobs?.data || []
|
||||
const flash = props.flash || {}
|
||||
|
||||
React.useEffect(() => {
|
||||
setFilters(props.filters || {})
|
||||
}, [props.filters])
|
||||
|
||||
function applyFilters(event) {
|
||||
event.preventDefault()
|
||||
router.get(props.indexUrl, filters, { preserveScroll: true, preserveState: true, replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full px-6 pb-16 pt-8">
|
||||
<Head title="Enhance Jobs" />
|
||||
|
||||
<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)]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Moderation surface</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Enhance Jobs</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Review queued, processing, failed, and completed image upscale jobs without changing original artwork assets.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={applyFilters} className="mt-6 grid gap-3 md:grid-cols-2 xl:grid-cols-7">
|
||||
<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">
|
||||
{(props.options?.statuses || []).map((option) => <option key={option} value={option}>{option === 'all' ? 'All statuses' : option}</option>)}
|
||||
</select>
|
||||
<select value={filters.engine || 'all'} onChange={(event) => setFilters((current) => ({ ...current, engine: event.target.value }))} className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none">
|
||||
{(props.options?.engines || []).map((option) => <option key={option} value={option}>{option === 'all' ? 'All engines' : option}</option>)}
|
||||
</select>
|
||||
<select value={filters.mode || 'all'} onChange={(event) => setFilters((current) => ({ ...current, mode: event.target.value }))} className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none">
|
||||
{(props.options?.modes || []).map((option) => <option key={option} value={option}>{option === 'all' ? 'All modes' : option}</option>)}
|
||||
</select>
|
||||
<select value={filters.scale || 'all'} onChange={(event) => setFilters((current) => ({ ...current, scale: event.target.value }))} className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none">
|
||||
{(props.options?.scales || []).map((option) => <option key={String(option)} value={option}>{option === 'all' ? 'All scales' : `${option}x`}</option>)}
|
||||
</select>
|
||||
<input value={filters.user || ''} onChange={(event) => setFilters((current) => ({ ...current, user: event.target.value }))} placeholder="User name or username" className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none" />
|
||||
<input type="date" value={filters.date_from || ''} onChange={(event) => setFilters((current) => ({ ...current, date_from: event.target.value }))} className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none" />
|
||||
<input type="date" value={filters.date_to || ''} onChange={(event) => setFilters((current) => ({ ...current, date_to: event.target.value }))} className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none" />
|
||||
<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] xl:col-span-7">Apply filters</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<EnhanceStubWarning config={props.enhanceConfig} moderation className="mt-6" />
|
||||
|
||||
{flash.success ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{flash.success}</div> : null}
|
||||
{flash.error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||
|
||||
<div className="mt-8 overflow-hidden rounded-[28px] border border-white/10 bg-[#08111d] shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.07] text-left text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
<th className="px-5 py-3.5">Preview</th>
|
||||
<th className="px-5 py-3.5">Job</th>
|
||||
<th className="px-5 py-3.5">User</th>
|
||||
<th className="px-5 py-3.5">Artwork</th>
|
||||
<th className="px-5 py-3.5">Status</th>
|
||||
<th className="px-5 py-3.5">Mode</th>
|
||||
<th className="px-5 py-3.5">Scale</th>
|
||||
<th className="px-5 py-3.5">Dimensions</th>
|
||||
<th className="px-5 py-3.5">Created</th>
|
||||
<th className="px-5 py-3.5 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{jobs.length === 0 ? <tr><td colSpan={10} className="px-5 py-12 text-center text-slate-400">No enhance jobs match the current filters.</td></tr> : null}
|
||||
{jobs.map((job) => (
|
||||
<tr key={job.id} className="transition hover:bg-white/[0.025]">
|
||||
<td className="px-5 py-4">
|
||||
<div className="h-16 w-16 overflow-hidden rounded-xl border border-white/10 bg-black/20">
|
||||
{job.preview_url || job.source_url ? <img src={job.preview_url || job.source_url} alt={`Enhance job ${job.id}`} className="h-full w-full object-cover" /> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-white">
|
||||
<div className="font-semibold">#{job.id}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{job.engine}</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-slate-300">{job.user?.name || '—'}{job.user?.username ? <div className="text-xs text-slate-500">@{job.user.username}</div> : null}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{job.artwork?.title ? <a href={job.artwork.url} className="text-sky-300 hover:text-sky-200">{job.artwork.title}</a> : '—'}</td>
|
||||
<td className="px-5 py-4"><EnhanceStatusBadge status={job.status} /></td>
|
||||
<td className="px-5 py-4 text-slate-300">{job.mode}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{job.scale}x</td>
|
||||
<td className="px-5 py-4 text-slate-300">{job.input_width} × {job.input_height}{job.output_width && job.output_height ? <div className="text-xs text-slate-500">→ {job.output_width} × {job.output_height}</div> : null}</td>
|
||||
<td className="px-5 py-4 text-slate-400">{formatDate(job.created_at)}</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<Link href={job.show_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</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
resources/js/Pages/Moderation/Enhance/Show.jsx
Normal file
118
resources/js/Pages/Moderation/Enhance/Show.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react'
|
||||
import BeforeAfterSlider from '../../../components/enhance/BeforeAfterSlider'
|
||||
import EnhanceStatusBadge from '../../../components/enhance/EnhanceStatusBadge'
|
||||
import EnhanceStubWarning from '../../../components/enhance/EnhanceStubWarning'
|
||||
import { formatEnhanceDate } from '../../../utils/enhanceFormatting'
|
||||
|
||||
function formatDate(value) {
|
||||
return formatEnhanceDate(value)
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 border-b border-white/[0.06] py-3 last:border-b-0 last:pb-0">
|
||||
<dt className="text-sm text-slate-400">{label}</dt>
|
||||
<dd className="text-right text-sm text-white">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ModerationEnhanceShow() {
|
||||
const { props } = usePage()
|
||||
const job = props.job || {}
|
||||
const flash = props.flash || {}
|
||||
const errors = props.errors || {}
|
||||
|
||||
return (
|
||||
<div className="w-full px-6 pb-16 pt-8">
|
||||
<Head title={`Enhance Job #${job.id || ''}`} />
|
||||
|
||||
<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)]">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EnhanceStatusBadge status={job.status} />
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.scale}x</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.mode}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.engine}</span>
|
||||
</div>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Enhance job #{job.id}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Created by {job.user?.name || 'Unknown user'} {job.user?.username ? `(@${job.user.username})` : ''}.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={props.indexUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
<i className="fa-solid fa-arrow-left text-[10px]" />
|
||||
Back to list
|
||||
</Link>
|
||||
{job.download_url ? <a href={job.download_url} className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-50 transition hover:bg-emerald-400/20">Download output</a> : null}
|
||||
{job.can_retry ? <button type="button" onClick={() => router.post(job.retry_url)} className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-amber-50 transition hover:bg-amber-400/20">Retry</button> : null}
|
||||
{job.can_mark_failed ? <button type="button" onClick={() => router.post(job.mark_failed_url)} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/20">Mark failed</button> : null}
|
||||
<button type="button" onClick={() => {
|
||||
if (!window.confirm('Delete this enhance job and any owned enhance files?')) return
|
||||
router.delete(job.delete_url)
|
||||
}} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EnhanceStubWarning config={props.enhanceConfig} moderation className="mt-6" />
|
||||
|
||||
{flash.success ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{flash.success}</div> : null}
|
||||
{flash.error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||
{errors.job ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{errors.job}</div> : null}
|
||||
|
||||
{job.source_url && job.output_url ? <div className="mt-8"><BeforeAfterSlider beforeUrl={job.source_url} afterUrl={job.output_url} /></div> : null}
|
||||
|
||||
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_380px]">
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Source image</div>
|
||||
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
|
||||
{job.source_url ? <img src={job.source_url} alt="Enhance source" className="w-full object-cover" /> : <div className="flex min-h-[280px] items-center justify-center text-white/20"><i className="fa-solid fa-image text-4xl" /></div>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Output image</div>
|
||||
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
|
||||
{job.output_url ? <img src={job.output_url} alt="Enhance output" className="w-full object-cover" /> : <div className="flex min-h-[280px] items-center justify-center text-white/20"><i className="fa-solid fa-hourglass-half text-4xl" /></div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Metadata</div>
|
||||
<div className="mt-4 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words">{JSON.stringify(job.metadata || {}, null, 2)}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{job.error_message ? <section className="rounded-[30px] border border-rose-300/20 bg-rose-400/10 p-6 text-sm text-rose-100 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">{job.error_message}</section> : null}
|
||||
</div>
|
||||
|
||||
<aside className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Processing details</div>
|
||||
<dl className="mt-4">
|
||||
<DetailRow label="Created" value={formatDate(job.created_at)} />
|
||||
<DetailRow label="Queued" value={formatDate(job.queued_at)} />
|
||||
<DetailRow label="Started" value={formatDate(job.started_at)} />
|
||||
<DetailRow label="Finished" value={formatDate(job.finished_at)} />
|
||||
<DetailRow label="Expires" value={formatDate(job.expires_at)} />
|
||||
<DetailRow label="Input mime" value={job.input_mime || '—'} />
|
||||
<DetailRow label="Input size" value={job.input_filesize ? `${(job.input_filesize / 1024 / 1024).toFixed(2)} MB` : '—'} />
|
||||
<DetailRow label="Input dimensions" value={job.input_width && job.input_height ? `${job.input_width} × ${job.input_height}` : '—'} />
|
||||
<DetailRow label="Output mime" value={job.output_mime || '—'} />
|
||||
<DetailRow label="Output size" value={job.output_filesize ? `${(job.output_filesize / 1024 / 1024).toFixed(2)} MB` : '—'} />
|
||||
<DetailRow label="Output dimensions" value={job.output_width && job.output_height ? `${job.output_width} × ${job.output_height}` : '—'} />
|
||||
<DetailRow label="Processing seconds" value={job.processing_seconds ?? '—'} />
|
||||
<DetailRow label="Artwork" value={job.artwork?.title ? <a href={job.artwork.url} className="text-sky-300 hover:text-sky-200">{job.artwork.title}</a> : 'Standalone upload'} />
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,20 +9,20 @@ function ensurePreviewOverlay() {
|
||||
}
|
||||
|
||||
previewOverlay = document.createElement('div')
|
||||
previewOverlay.className = 'fixed inset-0 z-[130] hidden items-center justify-center bg-[#020611e8] p-4 backdrop-blur-md'
|
||||
previewOverlay.className = 'fixed inset-0 z-[130] hidden items-center justify-center bg-[#020611bf] p-6 backdrop-blur-xl'
|
||||
previewOverlay.setAttribute('role', 'dialog')
|
||||
previewOverlay.setAttribute('aria-modal', 'true')
|
||||
previewOverlay.setAttribute('aria-label', 'Image preview')
|
||||
|
||||
const frame = document.createElement('div')
|
||||
frame.className = 'relative max-h-[92vh] max-w-6xl'
|
||||
frame.className = 'relative flex max-h-[92vh] w-full max-w-6xl flex-col items-center gap-4'
|
||||
|
||||
previewImage = document.createElement('img')
|
||||
previewImage.className = 'max-h-[92vh] max-w-full rounded-[28px] border border-white/10 shadow-[0_28px_90px_rgba(2,6,23,0.6)]'
|
||||
previewImage.className = 'max-h-[78vh] max-w-full rounded-[28px] border border-white/10 shadow-[0_28px_90px_rgba(2,6,23,0.45)]'
|
||||
previewImage.alt = 'Image preview'
|
||||
|
||||
previewCaption = document.createElement('div')
|
||||
previewCaption.className = 'absolute inset-x-0 bottom-0 rounded-b-[28px] bg-gradient-to-t from-black/80 to-transparent px-5 py-4 text-sm font-medium text-white/90'
|
||||
previewCaption.className = 'w-full max-w-4xl rounded-[24px] border border-white/10 bg-black/30 px-6 py-4 text-center text-sm font-medium leading-6 text-white/90 backdrop-blur-md'
|
||||
|
||||
const closeButton = document.createElement('button')
|
||||
closeButton.type = 'button'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
||||
import { usePage, Link } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import RichTextEditor from '../../components/forum/RichTextEditor'
|
||||
import UploadDescriptionEditor from '../../components/upload/UploadDescriptionEditor'
|
||||
import TextInput from '../../components/ui/TextInput'
|
||||
import Button from '../../components/ui/Button'
|
||||
import Modal from '../../components/ui/Modal'
|
||||
@@ -12,6 +12,7 @@ import TagPicker from '../../components/tags/TagPicker'
|
||||
import SchedulePublishPicker from '../../components/upload/SchedulePublishPicker'
|
||||
import ArtworkEvolutionSearchPicker from '../../components/artwork/ArtworkEvolutionSearchPicker'
|
||||
import WorldSubmissionSelector from '../../components/worlds/WorldSubmissionSelector'
|
||||
import { normalizeMarkdownLiteContent } from '../../utils/contentValidation'
|
||||
|
||||
const EDIT_SECTIONS = [
|
||||
{ id: 'taxonomy', label: 'Category', hint: 'Content type and category path' },
|
||||
@@ -302,7 +303,7 @@ export default function StudioArtworkEdit() {
|
||||
const [categoryId, setCategoryId] = useState(artwork?.parent_category_id || null)
|
||||
const [subCategoryId, setSubCategoryId] = useState(artwork?.sub_category_id || null)
|
||||
const [title, setTitle] = useState(artwork?.title || '')
|
||||
const [description, setDescription] = useState(artwork?.description || '')
|
||||
const [description, setDescription] = useState(() => normalizeMarkdownLiteContent(artwork?.description || ''))
|
||||
const [tagSlugs, setTagSlugs] = useState(() => (artwork?.tags || []).map((t) => t.slug || t.name))
|
||||
const [visibility, setVisibility] = useState(artwork?.visibility || (artwork?.is_public ? 'public' : 'private'))
|
||||
const [publishMode, setPublishMode] = useState(artwork?.publish_mode || (artwork?.artwork_status === 'scheduled' ? 'schedule' : 'now'))
|
||||
@@ -522,7 +523,7 @@ export default function StudioArtworkEdit() {
|
||||
const syncCurrentPayload = useCallback((current) => {
|
||||
if (!current) return
|
||||
setTitle(current.title || '')
|
||||
setDescription(current.description || '')
|
||||
setDescription(normalizeMarkdownLiteContent(current.description || ''))
|
||||
setTagSlugs(Array.isArray(current.tags) ? current.tags : [])
|
||||
setContentTypeId(current.content_type_id || null)
|
||||
setCategoryId(current.category_id || null)
|
||||
@@ -1473,13 +1474,13 @@ export default function StudioArtworkEdit() {
|
||||
/>
|
||||
|
||||
<FormField label={<FieldLabel label="Description" actionLabel="Description" onAction={() => requestAiIntent('description')} disabled={aiAction !== ''} loading={aiAction === 'analyze' || aiAction === 'regenerate'} />} htmlFor="artwork-description">
|
||||
<RichTextEditor
|
||||
content={description}
|
||||
<UploadDescriptionEditor
|
||||
id="artwork-description"
|
||||
value={description}
|
||||
onChange={handleDescriptionChange}
|
||||
placeholder="Describe your artwork, tools, inspiration…"
|
||||
error={errors.description?.[0]}
|
||||
minHeight={12}
|
||||
autofocus={false}
|
||||
rows={10}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
@@ -31,7 +31,13 @@ export default function StudioArtworks() {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<StudioContentBrowser listing={props.listing} quickCreate={props.quickCreate} hideModuleFilter />
|
||||
<StudioContentBrowser
|
||||
listing={props.listing}
|
||||
quickCreate={props.quickCreate}
|
||||
hideModuleFilter
|
||||
defaultSort="published_desc"
|
||||
sortStorageKey="studio-artworks-sort"
|
||||
/>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,14 @@ export default function StudioCardsIndex() {
|
||||
</section>
|
||||
|
||||
<section className="mt-8">
|
||||
<StudioContentBrowser listing={props.listing} quickCreate={props.quickCreate} hideModuleFilter emptyTitle="No cards yet" emptyBody="Create your first Nova card and it will appear here alongside your other Creator Studio content." />
|
||||
<StudioContentBrowser
|
||||
listing={props.listing}
|
||||
quickCreate={props.quickCreate}
|
||||
hideModuleFilter
|
||||
sortStorageKey="studio-cards-sort"
|
||||
emptyTitle="No cards yet"
|
||||
emptyBody="Create your first Nova card and it will appear here alongside your other Creator Studio content."
|
||||
/>
|
||||
</section>
|
||||
</StudioLayout>
|
||||
)
|
||||
|
||||
@@ -31,7 +31,12 @@ export default function StudioCollections() {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<StudioContentBrowser listing={props.listing} quickCreate={props.quickCreate} hideModuleFilter />
|
||||
<StudioContentBrowser
|
||||
listing={props.listing}
|
||||
quickCreate={props.quickCreate}
|
||||
hideModuleFilter
|
||||
sortStorageKey="studio-collections-sort"
|
||||
/>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
@@ -544,13 +544,26 @@ function NewsTagInput({ options, selectedIds, newTagNames, onSelectedIdsChange,
|
||||
}
|
||||
|
||||
function RelationCard({ relation, index, onChange, onRemove, onSearch, results, relationTypeOptions }) {
|
||||
const isSourceRelation = String(relation.entity_type || '').trim().toLowerCase() === 'source'
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)_auto] lg:items-end">
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
|
||||
<NovaSelect value={relation.entity_type} onChange={(val) => onChange(index, { ...relation, entity_type: val, entity_id: '', preview: null, query: '' })} options={relationTypeOptions} searchable={false} />
|
||||
<NovaSelect value={relation.entity_type} onChange={(val) => onChange(index, { ...relation, entity_type: val, entity_id: '', external_url: '', preview: null, query: '' })} options={relationTypeOptions} searchable={false} />
|
||||
</div>
|
||||
{isSourceRelation ? (
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Source URL</span>
|
||||
<input
|
||||
value={relation.external_url || ''}
|
||||
onChange={(event) => onChange(index, { ...relation, external_url: event.target.value, query: event.target.value, entity_id: '' })}
|
||||
placeholder="https://example.com/original-article"
|
||||
className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search entity</span>
|
||||
<div className="flex gap-2">
|
||||
@@ -558,19 +571,26 @@ function RelationCard({ relation, index, onChange, onRemove, onSearch, results,
|
||||
<button type="button" onClick={() => onSearch(index)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white">Search</button>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
<button type="button" onClick={() => onRemove(index)} className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Remove</button>
|
||||
</div>
|
||||
|
||||
{relation.preview ? (
|
||||
{!isSourceRelation && relation.preview ? (
|
||||
<div className="mt-4 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
|
||||
<div className="font-semibold">Linked: {relation.preview.title}</div>
|
||||
{relation.preview.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-emerald-100/70">{relation.preview.subtitle}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSourceRelation ? (
|
||||
<div className="mt-4 rounded-2xl border border-sky-300/15 bg-sky-400/10 px-4 py-3 text-sm text-sky-100/90">
|
||||
Source relations store a direct external URL instead of an internal Nova entity ID.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<SearchResultList items={results} onSelect={(item) => onChange(index, { ...relation, entity_id: item.id, preview: item, query: item.title })} emptyLabel="Search to attach a related entity." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="mt-4 grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Context label</span>
|
||||
@@ -584,6 +604,22 @@ function stripHtml(value) {
|
||||
return String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function unwrapMarkdownLinkUrl(value) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
|
||||
const markdownMatch = raw.match(/^\[[^\]]+\]\((https?:\/\/[^)]+)\)$/i)
|
||||
if (markdownMatch) {
|
||||
return String(markdownMatch[1] || '').trim()
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
function isSourceRelationType(entityType) {
|
||||
return String(entityType || '').trim().toLowerCase() === 'source'
|
||||
}
|
||||
|
||||
const NEWS_NEW_TAG_LIMIT = 30
|
||||
|
||||
function slugifyNewsTitle(value) {
|
||||
@@ -633,7 +669,8 @@ function buildSubmitPayload(data) {
|
||||
relations: Array.isArray(data.relations)
|
||||
? data.relations.map((relation) => ({
|
||||
entity_type: String(relation.entity_type || '').trim(),
|
||||
entity_id: relation.entity_id === '' || relation.entity_id == null ? '' : Number(relation.entity_id),
|
||||
entity_id: isSourceRelationType(relation.entity_type) || relation.entity_id === '' || relation.entity_id == null ? '' : Number(relation.entity_id),
|
||||
external_url: isSourceRelationType(relation.entity_type) ? unwrapMarkdownLinkUrl(relation.external_url || relation.entity_id || relation.query || '') : '',
|
||||
context_label: String(relation.context_label || '').trim(),
|
||||
}))
|
||||
: [],
|
||||
@@ -682,10 +719,13 @@ function buildInitialFormData(article, defaultAuthor, typeOptions, oldInput = {}
|
||||
og_image: String(getDraftValue(oldInput, 'og_image', article.og_image || '')),
|
||||
relations: Array.isArray(getDraftValue(oldInput, 'relations', article.relations)) ? getDraftValue(oldInput, 'relations', article.relations).map((relation) => ({
|
||||
entity_type: relation.entity_type || 'group',
|
||||
entity_id: relation.entity_id || '',
|
||||
entity_id: isSourceRelationType(relation.entity_type) ? '' : (relation.entity_id || ''),
|
||||
external_url: isSourceRelationType(relation.entity_type) ? unwrapMarkdownLinkUrl(relation.external_url || relation.entity_id || relation.query || '') : '',
|
||||
context_label: relation.context_label || '',
|
||||
preview: relation.preview || null,
|
||||
query: relation.preview?.title || '',
|
||||
query: isSourceRelationType(relation.entity_type)
|
||||
? unwrapMarkdownLinkUrl(relation.external_url || relation.entity_id || relation.query || '')
|
||||
: (relation.preview?.title || relation.query || ''),
|
||||
})) : [],
|
||||
}
|
||||
}
|
||||
@@ -808,7 +848,6 @@ function parseStructuredNewsImport(rawValue, context) {
|
||||
applyString('meta_keywords')
|
||||
applyString('og_title')
|
||||
applyString('og_description')
|
||||
applyString('og_image')
|
||||
|
||||
if (parsed.type != null) {
|
||||
const requested = String(parsed.type).trim().toLowerCase()
|
||||
@@ -869,13 +908,21 @@ function parseStructuredNewsImport(rawValue, context) {
|
||||
|
||||
if (Array.isArray(parsed.relations)) {
|
||||
next.relations = parsed.relations
|
||||
.map((relation) => ({
|
||||
entity_type: String(relation?.entity_type || relation?.type || 'group').trim(),
|
||||
entity_id: relation?.entity_id == null || relation?.entity_id === '' ? '' : Number(relation.entity_id),
|
||||
.map((relation) => {
|
||||
const entityType = String(relation?.entity_type || relation?.type || 'group').trim()
|
||||
const externalUrl = isSourceRelationType(entityType)
|
||||
? unwrapMarkdownLinkUrl(relation?.external_url || relation?.url || relation?.entity_id || relation?.query || relation?.title || '')
|
||||
: ''
|
||||
|
||||
return {
|
||||
entity_type: entityType,
|
||||
entity_id: isSourceRelationType(entityType) || relation?.entity_id == null || relation?.entity_id === '' ? '' : Number(relation.entity_id),
|
||||
external_url: externalUrl,
|
||||
context_label: String(relation?.context_label || relation?.label || '').trim(),
|
||||
preview: null,
|
||||
query: String(relation?.query || relation?.title || '').trim(),
|
||||
}))
|
||||
query: isSourceRelationType(entityType) ? externalUrl : String(relation?.query || relation?.title || '').trim(),
|
||||
}
|
||||
})
|
||||
.filter((relation) => relation.entity_type)
|
||||
applied.push('relations')
|
||||
}
|
||||
@@ -991,6 +1038,231 @@ function buildNewsMarkdownExport(data) {
|
||||
return lines.join('\n\n').trim()
|
||||
}
|
||||
|
||||
// ── News image prompt builder ────────────────────────────────────────────────
|
||||
|
||||
const NEWS_PROMPT_TYPE_MOODS = {
|
||||
announcement: 'Futuristic',
|
||||
release: 'Software Release',
|
||||
editorial: 'Editorial',
|
||||
opinion: 'Editorial',
|
||||
tutorial: 'Clean Instructional',
|
||||
platform_update: 'Modern Tech',
|
||||
event: 'Futuristic',
|
||||
challenge: 'Futuristic',
|
||||
interview: 'Editorial',
|
||||
spotlight: 'Editorial',
|
||||
archive: 'Retro Tech',
|
||||
industry_news: 'Modern Tech',
|
||||
review: 'Modern Tech',
|
||||
roundup: 'Modern Tech',
|
||||
}
|
||||
|
||||
const NEWS_PROMPT_TYPE_ADDONS = {
|
||||
release: 'Use a glossy software-release poster style with product UI panels, feature highlights, and a polished launch atmosphere.',
|
||||
announcement: 'Use a clean announcement-poster style with a strong headline, clear hero image, and supporting modules that communicate the main update quickly.',
|
||||
editorial: 'Use a refined editorial magazine-cover style with a strong visual concept, cleaner composition, and slightly more cinematic atmosphere.',
|
||||
opinion: 'Use a refined editorial magazine-cover style with a strong visual concept, cleaner composition, and slightly more cinematic atmosphere.',
|
||||
event: 'Use a conference or event-poster style with keynote energy, glowing screens, stage-like lighting, and a premium event atmosphere.',
|
||||
tutorial: 'Use a clear structured instructional poster style with organized UI panels, workflow callouts, and helpful visual hierarchy.',
|
||||
platform_update: 'Use a modern platform-update style with system UI visuals, feature modules, and a polished ecosystem presentation.',
|
||||
archive: 'Use a retro-tech editorial style inspired by early 2000s computer magazines, with classic hardware, vintage UI influences, and modern polished lighting.',
|
||||
}
|
||||
|
||||
const NEWS_PROMPT_KEYWORD_PATTERNS = [
|
||||
{
|
||||
keywords: ['apple', 'wwdc', 'ios', 'macos', 'iphone', 'ipad', 'swift'],
|
||||
addon: 'Use a sleek developer-conference atmosphere with modern device screens, app ecosystem visuals, and a premium keynote mood.',
|
||||
},
|
||||
{
|
||||
keywords: ['google', 'gemini', 'google i/o', 'android', 'pixel', 'tensorflow'],
|
||||
addon: 'Use a colorful futuristic creative AI studio style with glowing panels, image and video creation tools, search elements, and generative media visuals.',
|
||||
},
|
||||
{
|
||||
keywords: ['intel', 'amd', 'processor', 'cpu', 'gpu', 'nvidia', 'radeon', 'chip'],
|
||||
addon: 'Use a retro computing hardware feature style with processor chips, technical callouts, old-school PC references, and magazine-cover energy.',
|
||||
},
|
||||
{
|
||||
keywords: ['skin', 'theme', 'desktop', 'customize', 'customization', 'rainmeter', 'widget'],
|
||||
addon: 'Use a desktop customization promo style with theme previews, icon panels, widget windows, and a glossy desktop software aesthetic.',
|
||||
},
|
||||
{
|
||||
keywords: ['ai', 'artificial intelligence', 'llm', 'chatgpt', 'openai', 'midjourney', 'stable diffusion', 'generative'],
|
||||
addon: 'Use a colorful futuristic creative AI studio style with glowing panels, generative media outputs, neural network visuals, and advanced AI tool interfaces.',
|
||||
},
|
||||
]
|
||||
|
||||
function resolveNewsPromptHeadline(data) {
|
||||
return String(data.title || data.meta_title || '').trim() || 'Skinbase News'
|
||||
}
|
||||
|
||||
function resolveNewsPromptSubheadline(data) {
|
||||
const raw = String(data.excerpt || data.meta_description || '').replace(/<[^>]*>/g, '').trim()
|
||||
if (raw) {
|
||||
const words = raw.split(/\s+/)
|
||||
return words.slice(0, 18).join(' ') + (words.length > 18 ? '…' : '')
|
||||
}
|
||||
const plain = String(data.content || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
if (plain) {
|
||||
const sentence = plain.split(/[.!?]/)[0].trim()
|
||||
if (sentence.length > 10) {
|
||||
const words = sentence.split(/\s+/)
|
||||
return words.slice(0, 18).join(' ')
|
||||
}
|
||||
}
|
||||
return 'Latest technology and creative industry update'
|
||||
}
|
||||
|
||||
function resolveNewsPromptTopic(data) {
|
||||
const parts = []
|
||||
const cat = String(data.category || '').trim()
|
||||
if (cat) parts.push(cat)
|
||||
const tagList = (Array.isArray(data.tag_names) ? data.tag_names : []).slice(0, 5).filter(Boolean)
|
||||
if (tagList.length) parts.push(tagList.join(', '))
|
||||
if (!parts.length) {
|
||||
const words = String(data.title || '').split(/\s+/).filter((w) => w.length > 3).slice(0, 4)
|
||||
if (words.length) parts.push(words.join(' '))
|
||||
}
|
||||
return parts.join(' · ') || 'Technology and digital culture news'
|
||||
}
|
||||
|
||||
function resolveNewsPromptType(data) {
|
||||
const raw = String(data.type || '').trim()
|
||||
if (!raw) return 'News'
|
||||
return raw.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function resolveNewsPromptHeroSubject(data) {
|
||||
const title = String(data.title || '').toLowerCase()
|
||||
const tags = (Array.isArray(data.tag_names) ? data.tag_names : []).join(' ').toLowerCase()
|
||||
const combined = `${title} ${tags}`
|
||||
const type = String(data.type || '').toLowerCase()
|
||||
|
||||
if (/apple|wwdc|ios|macos/.test(combined)) return 'sleek developer conference scene with modern Apple devices, app ecosystem screens, and a keynote stage atmosphere'
|
||||
if (/google|gemini|google i\/o/.test(combined)) return 'futuristic creative AI workspace with Google AI tools, image and video generation screens, and colorful generative panels'
|
||||
if (/intel|amd|cpu|processor|gpu|nvidia|radeon/.test(combined)) return 'high-detail processor chip and PC hardware setup with technical callouts and magazine-style editorial framing'
|
||||
if (/\bai\b|artificial intelligence|llm|chatgpt|openai|midjourney|stable diffusion/.test(combined)) return 'futuristic AI creative studio with generative media outputs, neural network interfaces, and glowing AI panels'
|
||||
if (/skin|theme|desktop|customiz|rainmeter|widget/.test(combined)) return 'polished desktop customization interface with theme previews, icon panels, and widget windows on a dark desktop'
|
||||
if (/game|gaming/.test(combined)) return 'immersive gaming setup or game UI with dynamic lighting, modern peripherals, and a premium game atmosphere'
|
||||
if (/microsoft|windows/.test(combined)) return 'modern Windows interface with system UI panels, taskbar, settings, and a polished OS environment'
|
||||
if (type === 'tutorial') return 'organized instructional workflow panel with step-by-step UI callouts and visual hierarchy'
|
||||
if (type === 'event') return 'keynote conference stage with large screens, glowing hall, and event atmosphere'
|
||||
if (type === 'archive') return 'retro computing hardware from the early 2000s with classic monitors and vintage PC aesthetic'
|
||||
return 'professional editorial tech workspace with software screens, feature panels, and a polished digital newsroom atmosphere'
|
||||
}
|
||||
|
||||
function resolveNewsPromptSupportingModules(data) {
|
||||
const type = String(data.type || '').toLowerCase()
|
||||
const title = String(data.title || '').toLowerCase()
|
||||
const tags = (Array.isArray(data.tag_names) ? data.tag_names : []).join(' ').toLowerCase()
|
||||
const combined = `${title} ${tags}`
|
||||
|
||||
if (type === 'release' || /release|launch|version/.test(combined)) return 'version badge, feature highlight cards, changelog strip, UI screenshots, product icon panels'
|
||||
if (type === 'tutorial') return 'step-by-step panels, UI callouts, workflow arrows, numbered feature blocks'
|
||||
if (type === 'event') return 'schedule panels, speaker cards, keynote countdown, location badge, feature preview cards'
|
||||
if (type === 'archive') return 'retro spec badges, vintage hardware panels, timeline strip, era-appropriate UI screenshots'
|
||||
if (/\bai\b|artificial intelligence|generative/.test(combined)) return 'AI feature cards, generative output previews, glowing interface panels, model capability badges'
|
||||
if (/hardware|chip|cpu|gpu/.test(combined)) return 'performance charts, spec comparison cards, hardware close-ups, benchmark badges'
|
||||
return 'feature cards, interface panels, product highlights, mini screenshots, icon blocks'
|
||||
}
|
||||
|
||||
function resolveNewsPromptMood(data) {
|
||||
const type = String(data.type || '').toLowerCase().replace(/\s+/g, '_')
|
||||
return NEWS_PROMPT_TYPE_MOODS[type] || 'Modern Tech'
|
||||
}
|
||||
|
||||
function resolveNewsPromptTypeAddon(data) {
|
||||
const type = String(data.type || '').toLowerCase().replace(/\s+/g, '_')
|
||||
return NEWS_PROMPT_TYPE_ADDONS[type] || ''
|
||||
}
|
||||
|
||||
function resolveNewsPromptKeywordAddon(data) {
|
||||
const title = String(data.title || '').toLowerCase()
|
||||
const tags = (Array.isArray(data.tag_names) ? data.tag_names : []).join(' ').toLowerCase()
|
||||
const category = String(data.category || '').toLowerCase()
|
||||
const combined = `${title} ${tags} ${category}`
|
||||
const addons = []
|
||||
for (const pattern of NEWS_PROMPT_KEYWORD_PATTERNS) {
|
||||
if (pattern.keywords.some((kw) => combined.includes(kw))) {
|
||||
addons.push(pattern.addon)
|
||||
}
|
||||
}
|
||||
return [...new Set(addons)].join('\n')
|
||||
}
|
||||
|
||||
function buildNewsImagePrompt(data) {
|
||||
const headline = resolveNewsPromptHeadline(data)
|
||||
const subheadline = resolveNewsPromptSubheadline(data)
|
||||
const topic = resolveNewsPromptTopic(data)
|
||||
const newsType = resolveNewsPromptType(data)
|
||||
const heroSubject = resolveNewsPromptHeroSubject(data)
|
||||
const supportingModules = resolveNewsPromptSupportingModules(data)
|
||||
const mood = resolveNewsPromptMood(data)
|
||||
const typeAddon = resolveNewsPromptTypeAddon(data)
|
||||
const keywordAddon = resolveNewsPromptKeywordAddon(data)
|
||||
|
||||
const lines = [
|
||||
'Create a premium Skinbase news cover image in 16:9 aspect ratio.',
|
||||
'',
|
||||
'Design it as a professional editorial tech poster for a digital culture, software, hardware, AI, creative tools, desktop customization, or retro computing news article.',
|
||||
'',
|
||||
'ARTICLE DETAILS:',
|
||||
`Headline: "${headline}"`,
|
||||
`Subheadline: "${subheadline}"`,
|
||||
`Topic: ${topic}`,
|
||||
`News type: ${newsType}`,
|
||||
`Hero subject: ${heroSubject}`,
|
||||
`Supporting modules: ${supportingModules}`,
|
||||
`Mood: ${mood}`,
|
||||
'',
|
||||
'LAYOUT:',
|
||||
'Use a structured 16:9 news hero composition with:',
|
||||
'- Large bold headline in the upper-left or top-center',
|
||||
'- Smaller subtitle directly below the headline',
|
||||
'- One strong central hero visual',
|
||||
'- Supporting side panels, feature cards, icons, UI windows, diagrams, or mini screenshots',
|
||||
'- A bottom strip with 3 to 6 small highlight blocks or visual details',
|
||||
'- Clean spacing and a strong visual hierarchy',
|
||||
'',
|
||||
'VISUAL STYLE:',
|
||||
'Use a dark premium background with blue, cyan, violet, neon, or topic-matching accent colors. Add glossy highlights, subtle glow, cinematic depth, crisp lighting, and a polished high-tech editorial look.',
|
||||
'',
|
||||
'The image should feel like a professional magazine cover, software release poster, tech conference banner, or retro computing feature graphic. It should be visually rich, but still clean, readable, and organized.',
|
||||
'',
|
||||
'TEXT STYLE:',
|
||||
'Use bold clean sans-serif typography. Keep all visible text short and readable. Avoid long paragraphs inside the image. Use only short labels, feature names, or headline-style phrases.',
|
||||
'',
|
||||
'CONTENT DIRECTION:',
|
||||
'Represent the topic clearly through the central visual. Use relevant objects such as:',
|
||||
'- software windows',
|
||||
'- futuristic workstations',
|
||||
'- creative AI panels',
|
||||
'- computer chips',
|
||||
'- retro hardware',
|
||||
'- desktop customization elements',
|
||||
'- conference screens',
|
||||
'- app interface mockups',
|
||||
'- glowing diagrams',
|
||||
'- feature cards',
|
||||
'- product-style panels',
|
||||
'',
|
||||
'QUALITY RULES:',
|
||||
'Make it sharp, premium, polished, high detail, thumbnail-friendly, and suitable as a Skinbase news article cover image.',
|
||||
'',
|
||||
'Avoid clutter, random filler objects, unreadable microtext, messy typography, distorted UI, weak composition, watermarks, fake signatures, low-quality stock-photo style, and irrelevant logos.',
|
||||
]
|
||||
|
||||
if (typeAddon) {
|
||||
lines.push('', typeAddon)
|
||||
}
|
||||
if (keywordAddon) {
|
||||
lines.push('', keywordAddon)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildNewsExportPayloads(data, context = {}) {
|
||||
const normalized = buildSubmitPayload(data || {})
|
||||
const category = findNewsOptionById(context.categoryOptions, normalized.category_id)
|
||||
@@ -1058,12 +1330,14 @@ function buildNewsExportPayloads(data, context = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function JsonImportDialog({ open, value, error, onChange, onClose, onApply, exportPayloads, newTagLimit = NEWS_NEW_TAG_LIMIT }) {
|
||||
function JsonImportDialog({ open, value, error, onChange, onClose, onApply, exportPayloads, articleData = {}, newTagLimit = NEWS_NEW_TAG_LIMIT }) {
|
||||
const backdropRef = useRef(null)
|
||||
const [activeImportTab, setActiveImportTab] = useState('input')
|
||||
const [copyFeedback, setCopyFeedback] = useState('')
|
||||
const [exportMode, setExportMode] = useState('full')
|
||||
const [markdownExportText, setMarkdownExportText] = useState(String(exportPayloads?.markdown || ''))
|
||||
const [promptText, setPromptText] = useState('')
|
||||
const [promptIsManual, setPromptIsManual] = useState(false)
|
||||
|
||||
const importTabs = [
|
||||
{ id: 'input', label: 'Input', description: 'Paste JSON and apply it to the editor.' },
|
||||
@@ -1071,6 +1345,7 @@ function JsonImportDialog({ open, value, error, onChange, onClose, onApply, expo
|
||||
{ id: 'docs', label: 'Documentation', description: 'Field notes and mapping rules.' },
|
||||
{ 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.' },
|
||||
{ id: 'image_prompt', label: 'Image Prompt', description: 'Auto-generate a cover image prompt from article data.' },
|
||||
]
|
||||
|
||||
const structureExample = {
|
||||
@@ -1107,7 +1382,6 @@ function JsonImportDialog({ open, value, error, onChange, onClose, onApply, expo
|
||||
meta_keywords: 'sample news, structured import, editorial example',
|
||||
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',
|
||||
}
|
||||
|
||||
const newsJsonSchemaSummary = `You are generating a Skinbase news article JSON object.
|
||||
@@ -1131,7 +1405,7 @@ Recommended fields:
|
||||
- is_featured: boolean
|
||||
- is_pinned: boolean
|
||||
- meta_title, meta_description, meta_keywords
|
||||
- og_title, og_description, og_image
|
||||
- og_title, og_description
|
||||
- tags: array of strings or objects with name/title/label/slug
|
||||
- tag_names: array of strings
|
||||
- tag_ids: array of ids if you already know them
|
||||
@@ -1156,7 +1430,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, og_title, og_description, and og_image when available.
|
||||
- Fill meta_title, meta_description, og_title, and og_description when available.
|
||||
- Make comments_enabled true unless the source clearly says otherwise.
|
||||
|
||||
Input article text:
|
||||
@@ -1253,6 +1527,24 @@ Source article:
|
||||
}
|
||||
}, [activeImportTab, exportMode, exportPayloads, open])
|
||||
|
||||
// Auto-generate image prompt when the tab opens, or when article data changes
|
||||
// (unless the editor has manually modified the prompt text).
|
||||
useEffect(() => {
|
||||
if (!open || activeImportTab !== 'image_prompt') return
|
||||
if (promptIsManual) return
|
||||
setPromptText(buildNewsImagePrompt(articleData))
|
||||
}, [open, activeImportTab, articleData, promptIsManual])
|
||||
|
||||
const handleRegeneratePrompt = useCallback(() => {
|
||||
setPromptIsManual(false)
|
||||
setPromptText(buildNewsImagePrompt(articleData))
|
||||
}, [articleData])
|
||||
|
||||
const handleResetPrompt = useCallback(() => {
|
||||
setPromptIsManual(false)
|
||||
setPromptText(buildNewsImagePrompt(articleData))
|
||||
}, [articleData])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
@@ -1279,7 +1571,7 @@ Source article:
|
||||
</div>
|
||||
|
||||
<div className="border-b border-white/[0.06] px-4 py-4">
|
||||
<div className="grid gap-2 md:grid-cols-5">
|
||||
<div className="grid gap-2 grid-cols-2 md:grid-cols-3 lg:grid-cols-6">
|
||||
{importTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -1317,7 +1609,7 @@ Source article:
|
||||
<p>`is_featured`, `is_pinned`, `comments_enabled`</p>
|
||||
<p>`tags`, `tag_names`, `tag_ids`, `relations`</p>
|
||||
<p>`new_tag_names` is capped at {newTagLimit} items per article.</p>
|
||||
<p>`meta_title`, `meta_description`, `meta_keywords`, `og_title`, `og_description`, `og_image`</p>
|
||||
<p>`meta_title`, `meta_description`, `meta_keywords`, `og_title`, `og_description`</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1457,6 +1749,72 @@ Source article:
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeImportTab === 'image_prompt' ? (
|
||||
<div className="grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_320px]">
|
||||
<div className="grid gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||
Generated cover image prompt
|
||||
{promptIsManual ? <span className="ml-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-2 py-0.5 text-[10px] text-amber-100">Manually edited</span> : null}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRegeneratePrompt}
|
||||
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]"
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
{promptIsManual ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetPrompt}
|
||||
className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1.5 text-xs font-semibold text-amber-100 transition hover:bg-amber-400/20"
|
||||
>
|
||||
Reset to auto
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(promptText, 'Image prompt')}
|
||||
className="rounded-full border border-sky-300/25 bg-sky-400/90 px-3 py-1.5 text-xs font-semibold text-slate-950 transition hover:brightness-110"
|
||||
>
|
||||
Copy prompt
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={promptText}
|
||||
onChange={(event) => {
|
||||
setPromptText(event.target.value)
|
||||
setPromptIsManual(true)
|
||||
}}
|
||||
rows={22}
|
||||
spellCheck={false}
|
||||
className="nova-scrollbar w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm leading-6 text-white outline-none placeholder:text-white/30"
|
||||
placeholder="Opening the tab will generate a prompt automatically…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">How it works</div>
|
||||
<div className="mt-3 space-y-3 leading-6 text-slate-400">
|
||||
<p>The prompt is built automatically from the current article fields: title, excerpt, type, category, and tags.</p>
|
||||
<p>You can edit the prompt freely. It will be marked as <span className="rounded border border-amber-300/20 bg-amber-400/10 px-1 text-amber-100">Manually edited</span> once you change it.</p>
|
||||
<p>Click <strong className="text-slate-200">Regenerate</strong> or <strong className="text-slate-200">Reset to auto</strong> to rebuild from the current article state.</p>
|
||||
<p>Copy the prompt and paste it into an AI image generator such as Midjourney, DALL-E, Stable Diffusion, or Flux.</p>
|
||||
</div>
|
||||
<div className="mt-5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Auto-filled from</div>
|
||||
<ul className="mt-2 space-y-1 text-xs leading-6 text-slate-400">
|
||||
<li><span className="text-slate-300">Headline</span> — title, meta_title</li>
|
||||
<li><span className="text-slate-300">Subheadline</span> — excerpt, meta_description, content</li>
|
||||
<li><span className="text-slate-300">Topic</span> — category, tags</li>
|
||||
<li><span className="text-slate-300">Type</span> — article type</li>
|
||||
<li><span className="text-slate-300">Hero / Mood</span> — inferred from title, tags, type</li>
|
||||
<li><span className="text-slate-300">Addons</span> — type-based and keyword-based style blocks</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{copyFeedback ? (
|
||||
@@ -1490,7 +1848,7 @@ Source article:
|
||||
Copy export
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
) : activeImportTab === 'image_prompt' ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApply?.()}
|
||||
@@ -1529,6 +1887,7 @@ export default function StudioNewsEditor() {
|
||||
const normalizedInitialPayload = useMemo(() => JSON.stringify(buildSubmitPayload(initialFormData)), [initialFormData])
|
||||
const normalizedCurrentPayload = useMemo(() => JSON.stringify(buildSubmitPayload(form.data)), [form.data])
|
||||
const hasUnsavedChanges = normalizedCurrentPayload !== normalizedInitialPayload
|
||||
const frontendArticleUrl = String(article.canonical_url || '').trim()
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSyncedArticleKeyRef.current === articleSyncKey) {
|
||||
@@ -1646,6 +2005,25 @@ export default function StudioNewsEditor() {
|
||||
author: selectedAuthor,
|
||||
}), [form.data, props.categoryOptions, props.tagOptions, selectedAuthor])
|
||||
|
||||
const imagePromptArticleData = useMemo(() => {
|
||||
const category = findNewsOptionById(props.categoryOptions, form.data.category_id)
|
||||
const existingTags = findNewsTagsByIds(props.tagOptions, form.data.tag_ids)
|
||||
return {
|
||||
title: form.data.title,
|
||||
excerpt: form.data.excerpt,
|
||||
content: form.data.content,
|
||||
type: form.data.type,
|
||||
category: category?.name ?? category?.label ?? '',
|
||||
category_slug: category?.slug ?? '',
|
||||
tag_names: [
|
||||
...existingTags.map((t) => t.name),
|
||||
...(Array.isArray(form.data.new_tag_names) ? form.data.new_tag_names : []),
|
||||
],
|
||||
meta_title: form.data.meta_title,
|
||||
meta_description: form.data.meta_description,
|
||||
}
|
||||
}, [form.data, props.categoryOptions, props.tagOptions])
|
||||
|
||||
useEffect(() => {
|
||||
const firstErrorTab = NEWS_EDITOR_TABS.find((tab) => tabErrorCounts[tab.id] > 0)
|
||||
if (firstErrorTab) {
|
||||
@@ -1684,6 +2062,7 @@ export default function StudioNewsEditor() {
|
||||
{
|
||||
entity_type: props.relationTypeOptions?.[0]?.value || 'group',
|
||||
entity_id: '',
|
||||
external_url: '',
|
||||
context_label: '',
|
||||
preview: null,
|
||||
query: '',
|
||||
@@ -1825,8 +2204,8 @@ export default function StudioNewsEditor() {
|
||||
<ToastStack toasts={toasts} onDismiss={dismissToast} />
|
||||
<div className="space-y-6 pb-24">
|
||||
<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">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/10 px-5 py-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="grid gap-5 border-b border-white/10 px-5 py-4 lg:grid-cols-[minmax(0,1fr)_360px] lg:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
{props.indexUrl ? <a href={props.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 news list</a> : null}
|
||||
<span>{article.id ? `Article #${article.id}` : 'New article'}</span>
|
||||
@@ -1836,13 +2215,29 @@ export default function StudioNewsEditor() {
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">Keep the draft flow simple: write the story in one place, handle publishing in one place, and keep promotion metadata nearby instead of buried below the fold.</p>
|
||||
</div>
|
||||
|
||||
{coverPreviewUrl ? (
|
||||
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20 shadow-[0_18px_40px_rgba(2,6,23,0.35)]">
|
||||
<div className="relative aspect-[16/9] bg-black/30">
|
||||
<img src={coverPreviewUrl} alt={String(form.data.title || '').trim() || 'News cover preview'} className="h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#020611d9] via-[#02061144] to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">Header cover preview</div>
|
||||
<div className="mt-1 line-clamp-2 text-sm font-semibold text-white">{String(form.data.title || '').trim() || 'Cover image preview'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="sticky top-16 z-30 border-y border-white/10 bg-[linear-gradient(180deg,rgba(9,14,24,0.98),rgba(6,10,18,0.98))] px-4 py-3 backdrop-blur">
|
||||
<div className="flex justify-end gap-2 overflow-x-auto">
|
||||
{frontendArticleUrl ? <a href={frontendArticleUrl} target="_blank" rel="noreferrer" className="rounded-2xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-2.5 text-sm font-semibold text-cyan-100 transition hover:bg-cyan-400/15">Frontend link</a> : null}
|
||||
{props.previewUrl ? <a href={props.previewUrl} target="_blank" rel="noreferrer" className="rounded-2xl border border-indigo-300/20 bg-indigo-400/10 px-4 py-2.5 text-sm font-semibold text-indigo-100 transition hover:bg-indigo-400/15">Preview</a> : null}
|
||||
<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</button>
|
||||
<button type="submit" form="studio-news-editor-form" disabled={form.processing} 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-60">{form.processing ? 'Saving…' : 'Save article'}</button>
|
||||
<button type="submit" form="studio-news-editor-form" disabled={form.processing} className="inline-flex items-center gap-2 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-60">
|
||||
{hasUnsavedChanges && !form.processing ? <span className="h-2.5 w-2.5 rounded-full bg-rose-400 shadow-[0_0_10px_rgba(251,113,133,0.9)] animate-pulse" aria-hidden="true" /> : null}
|
||||
<span>{form.processing ? 'Saving…' : 'Save article'}</span>
|
||||
</button>
|
||||
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl)} className="rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-2.5 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-400/15">Publish now</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1965,6 +2360,11 @@ export default function StudioNewsEditor() {
|
||||
autofocus={false}
|
||||
advancedNews
|
||||
searchEntities={searchEntities}
|
||||
mediaSupport={{
|
||||
uploadUrl: props.coverUploadUrl,
|
||||
deleteUrl: props.coverDeleteUrl,
|
||||
slot: 'body',
|
||||
}}
|
||||
/>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-slate-400">
|
||||
Story workflow suggestion: lead with the change, explain why it matters, add supporting detail, then end with a clear call to action or next step.
|
||||
@@ -2187,6 +2587,7 @@ export default function StudioNewsEditor() {
|
||||
value={jsonImportValue}
|
||||
error={jsonImportError}
|
||||
exportPayloads={jsonExportPayloads}
|
||||
articleData={imagePromptArticleData}
|
||||
newTagLimit={props.newsTagLimit || NEWS_NEW_TAG_LIMIT}
|
||||
onChange={(nextValue) => {
|
||||
setJsonImportValue(nextValue)
|
||||
|
||||
@@ -30,7 +30,12 @@ export default function StudioStories() {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<StudioContentBrowser listing={props.listing} quickCreate={props.quickCreate} hideModuleFilter />
|
||||
<StudioContentBrowser
|
||||
listing={props.listing}
|
||||
quickCreate={props.quickCreate}
|
||||
hideModuleFilter
|
||||
sortStorageKey="studio-stories-sort"
|
||||
/>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
312
resources/js/Pages/Studio/__tests__/newsImagePrompt.test.js
Normal file
312
resources/js/Pages/Studio/__tests__/newsImagePrompt.test.js
Normal file
@@ -0,0 +1,312 @@
|
||||
// Vitest uses globals (configured in vite.config.mjs: test.globals = true).
|
||||
// Pure logic tests for the news image prompt builder helpers.
|
||||
// These helpers live in StudioNewsEditor.jsx — this file duplicates them
|
||||
// so they can be tested in isolation without pulling in the full editor bundle.
|
||||
|
||||
// ── Minimal copies of the prompt-builder helpers ──────────────────────────
|
||||
|
||||
const NEWS_PROMPT_TYPE_MOODS = {
|
||||
announcement: 'Futuristic',
|
||||
release: 'Software Release',
|
||||
editorial: 'Editorial',
|
||||
opinion: 'Editorial',
|
||||
tutorial: 'Clean Instructional',
|
||||
platform_update: 'Modern Tech',
|
||||
event: 'Futuristic',
|
||||
challenge: 'Futuristic',
|
||||
interview: 'Editorial',
|
||||
spotlight: 'Editorial',
|
||||
archive: 'Retro Tech',
|
||||
industry_news: 'Modern Tech',
|
||||
review: 'Modern Tech',
|
||||
roundup: 'Modern Tech',
|
||||
}
|
||||
|
||||
const NEWS_PROMPT_TYPE_ADDONS = {
|
||||
release: 'Use a glossy software-release poster style with product UI panels, feature highlights, and a polished launch atmosphere.',
|
||||
announcement: 'Use a clean announcement-poster style with a strong headline, clear hero image, and supporting modules that communicate the main update quickly.',
|
||||
editorial: 'Use a refined editorial magazine-cover style with a strong visual concept, cleaner composition, and slightly more cinematic atmosphere.',
|
||||
opinion: 'Use a refined editorial magazine-cover style with a strong visual concept, cleaner composition, and slightly more cinematic atmosphere.',
|
||||
event: 'Use a conference or event-poster style with keynote energy, glowing screens, stage-like lighting, and a premium event atmosphere.',
|
||||
tutorial: 'Use a clear structured instructional poster style with organized UI panels, workflow callouts, and helpful visual hierarchy.',
|
||||
platform_update: 'Use a modern platform-update style with system UI visuals, feature modules, and a polished ecosystem presentation.',
|
||||
archive: 'Use a retro-tech editorial style inspired by early 2000s computer magazines, with classic hardware, vintage UI influences, and modern polished lighting.',
|
||||
}
|
||||
|
||||
const NEWS_PROMPT_KEYWORD_PATTERNS = [
|
||||
{
|
||||
keywords: ['apple', 'wwdc', 'ios', 'macos', 'iphone', 'ipad', 'swift'],
|
||||
addon: 'Use a sleek developer-conference atmosphere with modern device screens, app ecosystem visuals, and a premium keynote mood.',
|
||||
},
|
||||
{
|
||||
keywords: ['google', 'gemini', 'google i/o', 'android', 'pixel', 'tensorflow'],
|
||||
addon: 'Use a colorful futuristic creative AI studio style with glowing panels, image and video creation tools, search elements, and generative media visuals.',
|
||||
},
|
||||
{
|
||||
keywords: ['intel', 'amd', 'processor', 'cpu', 'gpu', 'nvidia', 'radeon', 'chip'],
|
||||
addon: 'Use a retro computing hardware feature style with processor chips, technical callouts, old-school PC references, and magazine-cover energy.',
|
||||
},
|
||||
{
|
||||
keywords: ['skin', 'theme', 'desktop', 'customize', 'customization', 'rainmeter', 'widget'],
|
||||
addon: 'Use a desktop customization promo style with theme previews, icon panels, widget windows, and a glossy desktop software aesthetic.',
|
||||
},
|
||||
{
|
||||
keywords: ['ai', 'artificial intelligence', 'llm', 'chatgpt', 'openai', 'midjourney', 'stable diffusion', 'generative'],
|
||||
addon: 'Use a colorful futuristic creative AI studio style with glowing panels, generative media outputs, neural network visuals, and advanced AI tool interfaces.',
|
||||
},
|
||||
]
|
||||
|
||||
function resolveNewsPromptHeadline(data) {
|
||||
return String(data.title || data.meta_title || '').trim() || 'Skinbase News'
|
||||
}
|
||||
|
||||
function resolveNewsPromptSubheadline(data) {
|
||||
const raw = String(data.excerpt || data.meta_description || '').replace(/<[^>]*>/g, '').trim()
|
||||
if (raw) {
|
||||
const words = raw.split(/\s+/)
|
||||
return words.slice(0, 18).join(' ') + (words.length > 18 ? '…' : '')
|
||||
}
|
||||
const plain = String(data.content || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
if (plain) {
|
||||
const sentence = plain.split(/[.!?]/)[0].trim()
|
||||
if (sentence.length > 10) {
|
||||
const words = sentence.split(/\s+/)
|
||||
return words.slice(0, 18).join(' ')
|
||||
}
|
||||
}
|
||||
return 'Latest technology and creative industry update'
|
||||
}
|
||||
|
||||
function resolveNewsPromptTopic(data) {
|
||||
const parts = []
|
||||
const cat = String(data.category || '').trim()
|
||||
if (cat) parts.push(cat)
|
||||
const tagList = (Array.isArray(data.tag_names) ? data.tag_names : []).slice(0, 5).filter(Boolean)
|
||||
if (tagList.length) parts.push(tagList.join(', '))
|
||||
if (!parts.length) {
|
||||
const words = String(data.title || '').split(/\s+/).filter((w) => w.length > 3).slice(0, 4)
|
||||
if (words.length) parts.push(words.join(' '))
|
||||
}
|
||||
return parts.join(' · ') || 'Technology and digital culture news'
|
||||
}
|
||||
|
||||
function resolveNewsPromptType(data) {
|
||||
const raw = String(data.type || '').trim()
|
||||
if (!raw) return 'News'
|
||||
return raw.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function resolveNewsPromptMood(data) {
|
||||
const type = String(data.type || '').toLowerCase().replace(/\s+/g, '_')
|
||||
return NEWS_PROMPT_TYPE_MOODS[type] || 'Modern Tech'
|
||||
}
|
||||
|
||||
function resolveNewsPromptTypeAddon(data) {
|
||||
const type = String(data.type || '').toLowerCase().replace(/\s+/g, '_')
|
||||
return NEWS_PROMPT_TYPE_ADDONS[type] || ''
|
||||
}
|
||||
|
||||
function resolveNewsPromptKeywordAddon(data) {
|
||||
const title = String(data.title || '').toLowerCase()
|
||||
const tags = (Array.isArray(data.tag_names) ? data.tag_names : []).join(' ').toLowerCase()
|
||||
const category = String(data.category || '').toLowerCase()
|
||||
const combined = `${title} ${tags} ${category}`
|
||||
const addons = []
|
||||
for (const pattern of NEWS_PROMPT_KEYWORD_PATTERNS) {
|
||||
if (pattern.keywords.some((kw) => combined.includes(kw))) {
|
||||
addons.push(pattern.addon)
|
||||
}
|
||||
}
|
||||
return [...new Set(addons)].join('\n')
|
||||
}
|
||||
|
||||
function buildNewsImagePrompt(data) {
|
||||
const headline = resolveNewsPromptHeadline(data)
|
||||
const subheadline = resolveNewsPromptSubheadline(data)
|
||||
const topic = resolveNewsPromptTopic(data)
|
||||
const newsType = resolveNewsPromptType(data)
|
||||
const mood = resolveNewsPromptMood(data)
|
||||
const typeAddon = resolveNewsPromptTypeAddon(data)
|
||||
const keywordAddon = resolveNewsPromptKeywordAddon(data)
|
||||
|
||||
const lines = [
|
||||
'Create a premium Skinbase news cover image in 16:9 aspect ratio.',
|
||||
'',
|
||||
`Headline: "${headline}"`,
|
||||
`Subheadline: "${subheadline}"`,
|
||||
`Topic: ${topic}`,
|
||||
`News type: ${newsType}`,
|
||||
`Mood: ${mood}`,
|
||||
]
|
||||
if (typeAddon) lines.push('', typeAddon)
|
||||
if (keywordAddon) lines.push('', keywordAddon)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('resolveNewsPromptHeadline', () => {
|
||||
it('returns title when present', () => {
|
||||
expect(resolveNewsPromptHeadline({ title: 'My Title' })).toBe('My Title')
|
||||
})
|
||||
|
||||
it('falls back to meta_title', () => {
|
||||
expect(resolveNewsPromptHeadline({ title: '', meta_title: 'Meta Title' })).toBe('Meta Title')
|
||||
})
|
||||
|
||||
it('returns fallback when both are empty', () => {
|
||||
expect(resolveNewsPromptHeadline({})).toBe('Skinbase News')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewsPromptSubheadline', () => {
|
||||
it('returns excerpt truncated to 18 words', () => {
|
||||
const long = 'word '.repeat(25).trim()
|
||||
const result = resolveNewsPromptSubheadline({ excerpt: long })
|
||||
expect(result.endsWith('…')).toBe(true)
|
||||
expect(result.split(/\s+/).filter((w) => !w.startsWith('…') && w !== '…').length).toBeLessThanOrEqual(18)
|
||||
})
|
||||
|
||||
it('returns excerpt as-is when short', () => {
|
||||
expect(resolveNewsPromptSubheadline({ excerpt: 'Short excerpt.' })).toBe('Short excerpt.')
|
||||
})
|
||||
|
||||
it('strips HTML tags from excerpt', () => {
|
||||
expect(resolveNewsPromptSubheadline({ excerpt: '<p>Clean text</p>' })).toBe('Clean text')
|
||||
})
|
||||
|
||||
it('returns fallback when all fields are empty', () => {
|
||||
expect(resolveNewsPromptSubheadline({})).toBe('Latest technology and creative industry update')
|
||||
})
|
||||
|
||||
it('falls back to meta_description when excerpt is missing', () => {
|
||||
expect(resolveNewsPromptSubheadline({ meta_description: 'Meta desc' })).toBe('Meta desc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewsPromptTopic', () => {
|
||||
it('includes category and tags', () => {
|
||||
const result = resolveNewsPromptTopic({ category: 'Tech News', tag_names: ['AI', 'Google'] })
|
||||
expect(result).toContain('Tech News')
|
||||
expect(result).toContain('AI')
|
||||
expect(result).toContain('Google')
|
||||
})
|
||||
|
||||
it('falls back to title keywords when no category or tags', () => {
|
||||
const result = resolveNewsPromptTopic({ title: 'Some Long Title With Words' })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('returns generic fallback when all are empty', () => {
|
||||
expect(resolveNewsPromptTopic({})).toBe('Technology and digital culture news')
|
||||
})
|
||||
|
||||
it('caps tags at 5', () => {
|
||||
const data = { tag_names: ['a', 'b', 'c', 'd', 'e', 'f', 'g'] }
|
||||
const result = resolveNewsPromptTopic(data)
|
||||
expect(result.split(',').length).toBeLessThanOrEqual(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewsPromptType', () => {
|
||||
it('capitalizes and humanizes underscored types', () => {
|
||||
expect(resolveNewsPromptType({ type: 'platform_update' })).toBe('Platform Update')
|
||||
})
|
||||
|
||||
it('returns "News" when type is missing', () => {
|
||||
expect(resolveNewsPromptType({})).toBe('News')
|
||||
})
|
||||
|
||||
it('handles simple types correctly', () => {
|
||||
expect(resolveNewsPromptType({ type: 'editorial' })).toBe('Editorial')
|
||||
expect(resolveNewsPromptType({ type: 'release' })).toBe('Release')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewsPromptMood', () => {
|
||||
it('returns correct mood for known types', () => {
|
||||
expect(resolveNewsPromptMood({ type: 'archive' })).toBe('Retro Tech')
|
||||
expect(resolveNewsPromptMood({ type: 'tutorial' })).toBe('Clean Instructional')
|
||||
expect(resolveNewsPromptMood({ type: 'announcement' })).toBe('Futuristic')
|
||||
expect(resolveNewsPromptMood({ type: 'release' })).toBe('Software Release')
|
||||
})
|
||||
|
||||
it('returns Modern Tech fallback for unknown type', () => {
|
||||
expect(resolveNewsPromptMood({ type: 'unknown_type' })).toBe('Modern Tech')
|
||||
expect(resolveNewsPromptMood({})).toBe('Modern Tech')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewsPromptTypeAddon', () => {
|
||||
it('returns addon text for known types', () => {
|
||||
expect(resolveNewsPromptTypeAddon({ type: 'release' })).toContain('software-release poster')
|
||||
expect(resolveNewsPromptTypeAddon({ type: 'event' })).toContain('conference or event-poster')
|
||||
expect(resolveNewsPromptTypeAddon({ type: 'archive' })).toContain('retro-tech editorial')
|
||||
})
|
||||
|
||||
it('returns empty string for unknown type', () => {
|
||||
expect(resolveNewsPromptTypeAddon({ type: 'unknown' })).toBe('')
|
||||
expect(resolveNewsPromptTypeAddon({})).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewsPromptKeywordAddon', () => {
|
||||
it('detects apple/WWDC keyword in title', () => {
|
||||
const result = resolveNewsPromptKeywordAddon({ title: 'Apple WWDC 2026 Keynote', tag_names: [] })
|
||||
expect(result).toContain('developer-conference atmosphere')
|
||||
})
|
||||
|
||||
it('detects AI keyword in tags', () => {
|
||||
const result = resolveNewsPromptKeywordAddon({ title: 'New Update', tag_names: ['AI', 'Gemini'] })
|
||||
expect(result).toContain('creative AI studio')
|
||||
})
|
||||
|
||||
it('detects desktop customization keyword', () => {
|
||||
const result = resolveNewsPromptKeywordAddon({ title: 'Best desktop customization tools', tag_names: [] })
|
||||
expect(result).toContain('desktop customization promo')
|
||||
})
|
||||
|
||||
it('returns empty string when no keywords match', () => {
|
||||
const result = resolveNewsPromptKeywordAddon({ title: 'Random Article', tag_names: ['general'] })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('deduplicates addons when multiple patterns match the same text', () => {
|
||||
// "AI" matches both ai pattern; ensure no duplicates
|
||||
const result = resolveNewsPromptKeywordAddon({ title: 'ai ai ai', tag_names: ['ai', 'artificial intelligence'] })
|
||||
const lines = result.split('\n').filter(Boolean)
|
||||
expect(lines.length).toBe(new Set(lines).size)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNewsImagePrompt', () => {
|
||||
it('includes the article headline in the prompt', () => {
|
||||
const prompt = buildNewsImagePrompt({ title: 'Google I/O 2026 Keynote', type: 'announcement' })
|
||||
expect(prompt).toContain('Google I/O 2026 Keynote')
|
||||
})
|
||||
|
||||
it('includes the type addon for release articles', () => {
|
||||
const prompt = buildNewsImagePrompt({ title: 'App Launch', type: 'release' })
|
||||
expect(prompt).toContain('software-release poster')
|
||||
})
|
||||
|
||||
it('includes the keyword addon when tags match', () => {
|
||||
const prompt = buildNewsImagePrompt({ title: 'New skin', tag_names: ['desktop', 'theme'], type: 'release' })
|
||||
expect(prompt).toContain('desktop customization promo')
|
||||
})
|
||||
|
||||
it('uses fallback headline when title is missing', () => {
|
||||
const prompt = buildNewsImagePrompt({})
|
||||
expect(prompt).toContain('Skinbase News')
|
||||
})
|
||||
|
||||
it('contains the 16:9 aspect ratio instruction', () => {
|
||||
const prompt = buildNewsImagePrompt({ title: 'Test' })
|
||||
expect(prompt).toContain('16:9')
|
||||
})
|
||||
|
||||
it('contains the mood line', () => {
|
||||
const prompt = buildNewsImagePrompt({ title: 'Tutorial Article', type: 'tutorial' })
|
||||
expect(prompt).toContain('Clean Instructional')
|
||||
})
|
||||
})
|
||||
@@ -3,8 +3,10 @@ import { usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import TagInput from '../../components/tags/TagInput'
|
||||
import UploadWizard from '../../components/upload/UploadWizard'
|
||||
import UploadDescriptionEditor from '../../components/upload/UploadDescriptionEditor'
|
||||
import Checkbox from '../../Components/ui/Checkbox'
|
||||
import { mapUploadErrorNotice, mapUploadResultNotice } from '../../lib/uploadNotices'
|
||||
import { validateMarkdownLiteContent } from '../../utils/contentValidation'
|
||||
|
||||
const phases = {
|
||||
idle: 'idle',
|
||||
@@ -177,7 +179,7 @@ function getTypeKey(ct) {
|
||||
return String(ct.name || '').toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
|
||||
}
|
||||
|
||||
function useUploadMachine({ draftId, filesCdnUrl, chunkSize, chunkRequestTimeoutMs, userId }) {
|
||||
function useUploadMachine({ draftId = null, filesCdnUrl = '', chunkSize, chunkRequestTimeoutMs, userId = null } = {}) {
|
||||
const [state, dispatch] = useReducer(reducer, { ...initialState, draftId })
|
||||
const pollRef = useRef(null)
|
||||
const adaptiveChunkSizeRef = useRef(Math.max(1, Number(chunkSize || 0)))
|
||||
@@ -548,6 +550,14 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, chunkRequestTimeout
|
||||
return
|
||||
}
|
||||
|
||||
const descriptionErrors = validateMarkdownLiteContent(state.metadata.description)
|
||||
if (descriptionErrors.length > 0) {
|
||||
const message = descriptionErrors[0]
|
||||
dispatch({ type: 'UPLOAD_ERROR', error: message })
|
||||
pushNotice('error', message)
|
||||
return
|
||||
}
|
||||
|
||||
if (!state.metadata.licenseAccepted) {
|
||||
const message = 'You must confirm ownership of the artwork.'
|
||||
dispatch({ type: 'UPLOAD_ERROR', error: message })
|
||||
@@ -619,7 +629,7 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, chunkRequestTimeout
|
||||
}
|
||||
}
|
||||
|
||||
export default function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkRequestTimeoutMs }) {
|
||||
export default function UploadPage({ draftId = null, filesCdnUrl = '', chunkSize, chunkRequestTimeoutMs } = {}) {
|
||||
const { props } = usePage()
|
||||
const pageTitle = 'Upload Artwork — Creator Studio'
|
||||
const pageDescription = 'Submit a new artwork, complete the required metadata, and publish it from Skinbase Creator Studio.'
|
||||
@@ -745,6 +755,7 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkReque
|
||||
)
|
||||
const categoryOptions = useMemo(() => selectedType?.categories || [], [selectedType])
|
||||
const hasAtLeastOneTag = useMemo(() => parseUiTags(state.metadata.tags).length > 0, [state.metadata.tags])
|
||||
const descriptionErrors = useMemo(() => validateMarkdownLiteContent(state.metadata.description), [state.metadata.description])
|
||||
|
||||
useEffect(() => {
|
||||
// Prefer server-provided props, else try fetching from API endpoints
|
||||
@@ -1047,13 +1058,17 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkReque
|
||||
|
||||
<label className="mt-4 block text-sm">
|
||||
<span className="text-white/80">Description</span>
|
||||
<textarea
|
||||
<div className="mt-2">
|
||||
<UploadDescriptionEditor
|
||||
id="legacy-upload-description"
|
||||
value={state.metadata.description}
|
||||
onChange={(e) => dispatch({ type: 'SET_METADATA', payload: { description: e.target.value } })}
|
||||
className="mt-2 w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-white focus:border-sky-400 focus:outline-none"
|
||||
rows={4}
|
||||
onChange={(value) => dispatch({ type: 'SET_METADATA', payload: { description: value } })}
|
||||
placeholder="Tell the story behind this artwork."
|
||||
error={descriptionErrors[0] || ''}
|
||||
rows={8}
|
||||
/>
|
||||
</div>
|
||||
{descriptionErrors.length > 0 && <p className="mt-2 text-xs text-red-200">{descriptionErrors[0]}</p>}
|
||||
</label>
|
||||
|
||||
<div className="mt-4">
|
||||
@@ -1099,9 +1114,10 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkReque
|
||||
!state.metadata.category ||
|
||||
!hasAtLeastOneTag ||
|
||||
!state.metadata.description.trim() ||
|
||||
descriptionErrors.length > 0 ||
|
||||
!state.metadata.licenseAccepted
|
||||
}
|
||||
className={`inline-flex items-center gap-2 rounded-full px-5 py-2 text-sm font-semibold text-white ${(!state.file || !state.metadata.title.trim() || !state.metadata.type || !state.metadata.category || !hasAtLeastOneTag || !state.metadata.description.trim() || !state.metadata.licenseAccepted) ? 'bg-white/10 cursor-not-allowed' : 'bg-emerald-500 shadow-lg shadow-emerald-500/30'}`}
|
||||
className={`inline-flex items-center gap-2 rounded-full px-5 py-2 text-sm font-semibold text-white ${(!state.file || !state.metadata.title.trim() || !state.metadata.type || !state.metadata.category || !hasAtLeastOneTag || !state.metadata.description.trim() || descriptionErrors.length > 0 || !state.metadata.licenseAccepted) ? 'bg-white/10 cursor-not-allowed' : 'bg-emerald-500 shadow-lg shadow-emerald-500/30'}`}
|
||||
>
|
||||
<i className="fa-solid fa-rocket" aria-hidden="true"></i>
|
||||
Start upload
|
||||
|
||||
@@ -32,6 +32,11 @@ const pages = {
|
||||
'!./Pages/Academy/**/__tests__/**',
|
||||
'!./Pages/Academy/**/*.test.jsx',
|
||||
]),
|
||||
...import.meta.glob([
|
||||
'./Pages/Enhance/**/*.jsx',
|
||||
'!./Pages/Enhance/**/__tests__/**',
|
||||
'!./Pages/Enhance/**/*.test.jsx',
|
||||
]),
|
||||
}
|
||||
|
||||
function resolvePage(name) {
|
||||
|
||||
@@ -389,6 +389,8 @@ export default function StudioContentBrowser({
|
||||
quickCreate = [],
|
||||
hideModuleFilter = false,
|
||||
hideBucketFilter = false,
|
||||
defaultSort = 'updated_desc',
|
||||
sortStorageKey = null,
|
||||
emptyTitle = 'Nothing here yet',
|
||||
emptyBody = 'Try adjusting filters or create something new.',
|
||||
}) {
|
||||
@@ -400,7 +402,7 @@ export default function StudioContentBrowser({
|
||||
const [pendingFilters, setPendingFilters] = useState({
|
||||
q: '',
|
||||
bucket: 'all',
|
||||
sort: 'updated_desc',
|
||||
sort: defaultSort,
|
||||
content_type: 'all',
|
||||
category: 'all',
|
||||
tag: '',
|
||||
@@ -466,12 +468,41 @@ export default function StudioContentBrowser({
|
||||
setPendingFilters({
|
||||
q: filters.q || '',
|
||||
bucket: filters.bucket || 'all',
|
||||
sort: filters.sort || 'updated_desc',
|
||||
sort: filters.sort || defaultSort,
|
||||
content_type: filters.content_type || 'all',
|
||||
category: filters.category || 'all',
|
||||
tag: filters.tag || '',
|
||||
})
|
||||
}, [filters.q, filters.bucket, filters.sort, filters.content_type, filters.category, filters.tag])
|
||||
}, [filters.q, filters.bucket, filters.sort, filters.content_type, filters.category, filters.tag, defaultSort])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sortStorageKey) {
|
||||
return
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.has('sort')) {
|
||||
return
|
||||
}
|
||||
|
||||
const storedSort = window.localStorage.getItem(sortStorageKey)
|
||||
const sortOptions = new Set((listing?.sort_options || []).map((option) => option.value))
|
||||
const activeSort = filters.sort || defaultSort
|
||||
|
||||
if (!storedSort || !sortOptions.has(storedSort) || storedSort === activeSort) {
|
||||
return
|
||||
}
|
||||
|
||||
router.get(window.location.pathname, {
|
||||
...filters,
|
||||
sort: storedSort,
|
||||
page: 1,
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
})
|
||||
}, [sortStorageKey, listing?.sort_options, filters, defaultSort])
|
||||
|
||||
const updateQuery = (patch) => {
|
||||
const next = {
|
||||
@@ -491,6 +522,10 @@ export default function StudioContentBrowser({
|
||||
},
|
||||
})
|
||||
|
||||
if (sortStorageKey && typeof next.sort === 'string' && next.sort !== '') {
|
||||
window.localStorage.setItem(sortStorageKey, next.sort)
|
||||
}
|
||||
|
||||
router.get(window.location.pathname, next, {
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
@@ -882,7 +917,7 @@ export default function StudioContentBrowser({
|
||||
id="studio-filter-sort"
|
||||
options={selectOptions(listing?.sort_options || [])}
|
||||
value={pendingFilters.sort}
|
||||
onChange={(nextValue) => setPendingFilter('sort', nextValue ?? 'updated_desc')}
|
||||
onChange={(nextValue) => setPendingFilter('sort', nextValue ?? defaultSort)}
|
||||
placeholder="Recently updated"
|
||||
searchable={false}
|
||||
/>
|
||||
|
||||
@@ -56,6 +56,7 @@ export default function Topbar({ user = null }) {
|
||||
</a>
|
||||
<div className="border-t border-neutral-700" />
|
||||
<a href={user.uploadUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Upload</a>
|
||||
<a href="/enhance" className="block px-4 py-2 text-sm hover:bg-white/5">Enhance</a>
|
||||
<a href="/studio/artworks" className="block px-4 py-2 text-sm hover:bg-white/5">Studio</a>
|
||||
{user.moderationUrl ? <a href={user.moderationUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Moderation</a> : null}
|
||||
<a href="/dashboard" className="block px-4 py-2 text-sm hover:bg-white/5">Dashboard</a>
|
||||
|
||||
@@ -51,6 +51,15 @@ function ChartIcon() {
|
||||
)
|
||||
}
|
||||
|
||||
function EnhanceIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3l1.9 4.6L18.5 9l-4.6 1.4L12 15l-1.9-4.6L5.5 9l4.6-1.4L12 3Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 15l.95 2.05L21 18l-2.05.95L18 21l-.95-2.05L15 18l2.05-.95L18 15Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ShareIcon removed — now provided by ArtworkShareButton */
|
||||
|
||||
function FlagIcon() {
|
||||
@@ -215,6 +224,9 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
const shareUrl = canonicalUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
|
||||
const analyticsUrl = artwork?.management?.analytics_url
|
||||
|| (artwork?.viewer?.is_owner ? `/studio/artworks/${artwork.id}/analytics` : null)
|
||||
const enhanceUrl = artwork?.viewer?.is_owner && artwork?.id
|
||||
? `/enhance/create?artwork=${encodeURIComponent(artwork.id)}`
|
||||
: null
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
@@ -374,6 +386,16 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{enhanceUrl ? (
|
||||
<a
|
||||
href={enhanceUrl}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-violet-300/25 bg-violet-400/12 px-5 py-2.5 text-sm font-medium text-violet-50 transition-all duration-200 hover:border-violet-200/40 hover:bg-violet-400/18 hover:text-white"
|
||||
>
|
||||
<EnhanceIcon />
|
||||
Enhance image
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{/* Report pill */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -451,6 +473,18 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{enhanceUrl ? (
|
||||
<a
|
||||
href={enhanceUrl}
|
||||
aria-label="Enhance artwork image"
|
||||
title="Enhance image"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-violet-300/25 bg-violet-400/12 px-3.5 py-2 text-xs font-medium text-violet-50 transition-all hover:border-violet-200/40 hover:bg-violet-400/18 hover:text-white"
|
||||
>
|
||||
<EnhanceIcon />
|
||||
Enhance
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{/* Report */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
53
resources/js/components/enhance/BeforeAfterSlider.jsx
Normal file
53
resources/js/components/enhance/BeforeAfterSlider.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function BeforeAfterSlider({ beforeUrl, afterUrl, beforeAlt = 'Original image', afterAlt = 'Enhanced image' }) {
|
||||
const [position, setPosition] = React.useState(50)
|
||||
|
||||
if (!beforeUrl || !afterUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Before / after</p>
|
||||
<h3 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">Compare the original with the enhanced result</h3>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-200">{position}%</span>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-5 overflow-hidden rounded-[24px] border border-white/10 bg-black/40">
|
||||
<img src={beforeUrl} alt={beforeAlt} className="block w-full object-cover" />
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 overflow-hidden border-r border-white/80" style={{ width: `${position}%` }}>
|
||||
<img src={afterUrl} alt={afterAlt} className="block h-full w-full object-cover" />
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0" style={{ left: `calc(${position}% - 1px)` }}>
|
||||
<div className="flex h-full items-center">
|
||||
<div className="flex h-10 w-10 -translate-x-1/2 items-center justify-center rounded-full border border-white/80 bg-black/60 text-white shadow-[0_0_30px_rgba(15,23,42,0.5)]">
|
||||
<i className="fa-solid fa-left-right text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute left-3 top-3 rounded-full border border-white/10 bg-[#08111dd8] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-100">Original</div>
|
||||
<div className="pointer-events-none absolute right-3 top-3 rounded-full border border-emerald-300/20 bg-emerald-400/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-100">Enhanced</div>
|
||||
</div>
|
||||
|
||||
<label className="mt-5 block">
|
||||
<span className="sr-only">Adjust before and after comparison slider</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
value={position}
|
||||
onChange={(event) => setPosition(Number(event.target.value || 50))}
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-white/10 accent-sky-300"
|
||||
aria-label="Adjust before and after comparison slider"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
resources/js/components/enhance/EnhanceStatusBadge.jsx
Normal file
34
resources/js/components/enhance/EnhanceStatusBadge.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
|
||||
const TONES = {
|
||||
pending: 'border-white/10 bg-white/[0.05] text-slate-200',
|
||||
queued: 'border-sky-300/20 bg-sky-400/12 text-sky-100',
|
||||
processing: 'border-violet-300/20 bg-violet-400/12 text-violet-100',
|
||||
completed: 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100',
|
||||
failed: 'border-rose-300/20 bg-rose-400/12 text-rose-100',
|
||||
cancelled: 'border-amber-300/20 bg-amber-400/12 text-amber-100',
|
||||
expired: 'border-white/10 bg-white/[0.05] text-slate-300',
|
||||
unknown: 'border-white/10 bg-white/[0.04] text-slate-300',
|
||||
}
|
||||
|
||||
const LABELS = {
|
||||
pending: 'Pending',
|
||||
queued: 'Queued',
|
||||
processing: 'Processing',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled',
|
||||
expired: 'Expired',
|
||||
}
|
||||
|
||||
export default function EnhanceStatusBadge({ status, className = '' }) {
|
||||
const key = String(status || '').toLowerCase()
|
||||
const tone = TONES[key] || TONES.unknown
|
||||
const label = LABELS[key] || 'Unknown'
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${tone} ${className}`.trim()}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
14
resources/js/components/enhance/EnhanceStubWarning.jsx
Normal file
14
resources/js/components/enhance/EnhanceStubWarning.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function EnhanceStubWarning({ config, moderation = false, className = '' }) {
|
||||
if (!config?.showStubWarning) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-50 ${className}`.trim()}>
|
||||
<div>Skinbase Enhance is currently running in preview mode. The generated result is a workflow placeholder until the real upscaling worker is enabled.</div>
|
||||
{moderation ? <div className="mt-2 text-xs uppercase tracking-[0.14em] text-amber-100/80">Engine: {config.engine}. This is not a real AI upscale result.</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
239
resources/js/components/upload/UploadDescriptionEditor.jsx
Normal file
239
resources/js/components/upload/UploadDescriptionEditor.jsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import EmojiPickerButton from '../comments/EmojiPickerButton'
|
||||
|
||||
function ToolbarButton({ title, onClick, children, className = '' }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
onClick?.()
|
||||
}}
|
||||
className={[
|
||||
'inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-xs font-semibold text-white/60 transition',
|
||||
'hover:bg-white/10 hover:text-white',
|
||||
className,
|
||||
].join(' ')}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UploadDescriptionEditor({ id, value, onChange, placeholder, error, rows = 8 }) {
|
||||
const [tab, setTab] = useState('write')
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
const focusTextarea = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus()
|
||||
})
|
||||
}, [])
|
||||
|
||||
const wrapSelection = useCallback((before, after, placeholderText = 'text') => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const replacement = `${before}${selected || placeholderText}${after}`
|
||||
const next = current.slice(0, start) + replacement + current.slice(end)
|
||||
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
if (selected) {
|
||||
textarea.selectionStart = start + replacement.length
|
||||
textarea.selectionEnd = start + replacement.length
|
||||
} else {
|
||||
textarea.selectionStart = start + before.length
|
||||
textarea.selectionEnd = start + before.length + placeholderText.length
|
||||
}
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const prefixLines = useCallback((prefix) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const fallback = prefix.endsWith('. ') ? `${prefix}item` : `${prefix}item`
|
||||
const source = selected || fallback
|
||||
const nextBlock = source.split('\n').map((line) => `${prefix}${line}`).join('\n')
|
||||
const next = current.slice(0, start) + nextBlock + current.slice(end)
|
||||
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.selectionStart = start
|
||||
textarea.selectionEnd = start + nextBlock.length
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const replacement = selected && /^https?:\/\//i.test(selected)
|
||||
? `[link](${selected})`
|
||||
: `[link](https://)`
|
||||
const next = current.slice(0, start) + replacement + current.slice(end)
|
||||
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
if (selected && /^https?:\/\//i.test(selected)) {
|
||||
textarea.selectionStart = start + 1
|
||||
textarea.selectionEnd = start + 5
|
||||
} else {
|
||||
const urlStart = start + replacement.indexOf('https://')
|
||||
textarea.selectionStart = urlStart
|
||||
textarea.selectionEnd = urlStart + 'https://'.length
|
||||
}
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const insertAtCursor = useCallback((text) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) {
|
||||
onChange?.(`${String(value || '')}${text}`)
|
||||
return
|
||||
}
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart ?? current.length
|
||||
const end = textarea.selectionEnd ?? current.length
|
||||
const next = current.slice(0, start) + text + current.slice(end)
|
||||
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.selectionStart = start + text.length
|
||||
textarea.selectionEnd = start + text.length
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const handleKeyDown = useCallback((event) => {
|
||||
const withModifier = event.ctrlKey || event.metaKey
|
||||
if (!withModifier) return
|
||||
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'b':
|
||||
event.preventDefault()
|
||||
wrapSelection('**', '**')
|
||||
break
|
||||
case 'i':
|
||||
event.preventDefault()
|
||||
wrapSelection('*', '*')
|
||||
break
|
||||
case 'k':
|
||||
event.preventDefault()
|
||||
insertLink()
|
||||
break
|
||||
case 'e':
|
||||
event.preventDefault()
|
||||
wrapSelection('`', '`')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [insertLink, wrapSelection])
|
||||
|
||||
const previewValue = String(value || '').trim()
|
||||
|
||||
return (
|
||||
<div className={`overflow-hidden rounded-xl border bg-white/10 ${error ? 'border-red-300/60' : 'border-white/15'}`}>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('write')}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'write' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('preview')}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'preview' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-white/40">
|
||||
Safe formatting only
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'write' && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-1 border-b border-white/10 px-2 py-1">
|
||||
<ToolbarButton title="Bold (Ctrl+B)" onClick={() => wrapSelection('**', '**')}>B</ToolbarButton>
|
||||
<ToolbarButton title="Italic (Ctrl+I)" onClick={() => wrapSelection('*', '*')}>I</ToolbarButton>
|
||||
<ToolbarButton title="Inline code (Ctrl+E)" onClick={() => wrapSelection('`', '`')}>{'</>'}</ToolbarButton>
|
||||
<ToolbarButton title="Link (Ctrl+K)" onClick={insertLink}>Link</ToolbarButton>
|
||||
<span className="mx-1 h-4 w-px bg-white/10" aria-hidden="true" />
|
||||
<ToolbarButton title="Bulleted list" onClick={() => prefixLines('- ')}>List</ToolbarButton>
|
||||
<ToolbarButton title="Numbered list" onClick={() => prefixLines('1. ')}>1.</ToolbarButton>
|
||||
<ToolbarButton title="Quote" onClick={() => prefixLines('> ')}>Quote</ToolbarButton>
|
||||
<span className="mx-1 h-4 w-px bg-white/10" aria-hidden="true" />
|
||||
<EmojiPickerButton onEmojiSelect={insertAtCursor} className="h-8 w-8 rounded-md text-white/60 hover:bg-white/10 hover:text-white" />
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id={id}
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={rows}
|
||||
className="w-full resize-y bg-transparent px-3 py-3 text-sm text-white placeholder-white/45 focus:outline-none"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-3 pb-2 text-[11px] text-white/45">
|
||||
<span>Supports bold, italic, code, links, lists, quotes, and emoji.</span>
|
||||
<button type="button" onClick={focusTextarea} className="text-white/50 transition hover:text-white/80">Continue editing</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'preview' && (
|
||||
<div className="min-h-[188px] px-3 py-3">
|
||||
{previewValue ? (
|
||||
<div className="prose prose-invert prose-sm max-w-none text-white/85 [&_a]:text-sky-300 [&_a]:no-underline hover:[&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-white/20 [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-white/10 [&_code]:px-1 [&_code]:py-0.5 [&_ul]:pl-4 [&_ol]:pl-4">
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer nofollow">{children}</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{previewValue}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm italic text-white/35">Nothing to preview yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
import TagPicker from '../tags/TagPicker'
|
||||
import Checkbox from '../../Components/ui/Checkbox'
|
||||
import RichTextEditor from '../forum/RichTextEditor'
|
||||
import SchedulePublishPicker from './SchedulePublishPicker'
|
||||
import UploadDescriptionEditor from './UploadDescriptionEditor'
|
||||
|
||||
export default function UploadSidebar({
|
||||
title = 'Artwork details',
|
||||
@@ -53,15 +53,17 @@ export default function UploadSidebar({
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-white/90">Description <span className="text-red-300">*</span></span>
|
||||
<div className="mt-2">
|
||||
<RichTextEditor
|
||||
content={metadata.description}
|
||||
<UploadDescriptionEditor
|
||||
id="upload-sidebar-description"
|
||||
value={metadata.description}
|
||||
onChange={onChangeDescription}
|
||||
placeholder="Describe your artwork, tools, inspiration…"
|
||||
placeholder="Describe your artwork, tools, inspiration..."
|
||||
error={Array.isArray(errors.description) ? errors.description[0] : errors.description}
|
||||
minHeight={12}
|
||||
autofocus={false}
|
||||
rows={9}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-white/50">This upload editor only allows safe formatting and emoji. Images, embeds, and raw HTML are blocked.</p>
|
||||
{errors.description && <p className="mt-1 text-xs text-red-200">{Array.isArray(errors.description) ? errors.description[0] : errors.description}</p>}
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user